diff --git a/.dockerignore b/.dockerignore index c1689c398..798b4fc82 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,51 @@ +# Git / workspace metadata +.git +.github +.devcontainer +.husky +.codex +.vscode + +# Dependency installs and caches +node_modules +**/node_modules +.turbo +**/.turbo +**/.next +**/out +**/build +**/dist +**/standalone +**/coverage + +# Docs and generated content LICENSE NOTICE +README.md .prettierrc .prettierignore -README.md .gitignore -.husky -.github -.devcontainer .env.example -node_modules \ No newline at end of file +/apps/docs/.source +/apps/docs/.contentlayer +/apps/docs/.content-collections +/apps/docs/.next +/apps/docs/out +/apps/docs/build +/apps/docs/coverage + +# Environment and local secrets +.env +*.env +.env.local +.env.development +.env.test +.env.production + +# Miscellaneous +*.log +*.map +.DS_Store +*.pem +**/postgres_data/ +CURSOR_MEMORY diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fdc7336a3..4521cd1c8 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -154,10 +154,21 @@ After running this command, open [http://localhost:3000/](http://localhost:3000/ git clone https://github.com//TradingGoose-Studio.git cd TradingGoose-Studio -# Start TradingGoose -docker compose -f docker-compose.prod.yml up -d +# Copy the Docker Compose env template and set the required secrets/tags +cp apps/tradinggoose/.env.example.docker apps/tradinggoose/.env + +docker compose --env-file ./apps/tradinggoose/.env -f docker-compose.prod.yml up -d ``` +Your Docker `.env` must include `POSTGRES_*`, `NEXT_PUBLIC_APP_URL`, +`NEXT_PUBLIC_SOCKET_URL`, `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, +`API_ENCRYPTION_KEY`, and `INTERNAL_API_SECRET`. The `ENCRYPTION_KEY` value +must be available to both the app and realtime containers. Use +`http://localhost:3002` for `NEXT_PUBLIC_SOCKET_URL` in local Compose runs, and +override it with a browser-reachable public URL for production. The prod and +Ollama compose files also require `IMAGE_TAG` and `OLLAMA_IMAGE_TAG` +respectively. + Access the application at [http://localhost:3000/](http://localhost:3000/) #### Using Local Models @@ -178,14 +189,13 @@ ollama pull gemma3:4b ```bash # With NVIDIA GPU support -docker compose --profile local-gpu -f docker-compose.ollama.yml up -d +docker compose --env-file ./apps/tradinggoose/.env --profile gpu --profile setup -f docker-compose.ollama.yml up -d # Without GPU (CPU only) -docker compose --profile local-cpu -f docker-compose.ollama.yml up -d +docker compose --env-file ./apps/tradinggoose/.env --profile cpu --profile setup -f docker-compose.ollama.yml up -d -# If hosting on a server, update the environment variables in the docker-compose.prod.yml file -# to include the server's public IP then start again (OLLAMA_URL to i.e. http://1.1.1.1:11434) -docker compose -f docker-compose.prod.yml up -d +# If hosting on a server, point OLLAMA_URL in .env to the remote endpoint +# before starting docker compose -f docker-compose.prod.yml up -d ``` ### Option 3: Using VS Code / Cursor Dev Containers @@ -238,7 +248,8 @@ If you prefer not to use Docker or Dev Containers: cd apps/tradinggoose ``` - Copy `.env.example` to `.env` - - Configure required variables (DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL) + - Configure required variables (DATABASE_URL, NEXT_PUBLIC_APP_URL, BETTER_AUTH_SECRET, ENCRYPTION_KEY, INTERNAL_API_SECRET) + - Add `API_ENCRYPTION_KEY` if you want encrypted API-key storage in local development 3. **Set Up Database:** diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index e1d380459..1ac3a7b73 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -5,178 +5,82 @@ on: branches: [main] workflow_dispatch: +concurrency: + group: build-and-push-images + cancel-in-progress: false + permissions: contents: read packages: write - id-token: write jobs: - build-amd64: - name: Build AMD64 + build-images: + name: Build Images runs-on: blacksmith-4vcpu-ubuntu-2404 strategy: fail-fast: false matrix: include: - dockerfile: ./docker/app.Dockerfile - ghcr_image: ghcr.io/tradinggoose/tradinggoose - ecr_repo_secret: ECR_APP + repo: tradinggoose - dockerfile: ./docker/db.Dockerfile - ghcr_image: ghcr.io/tradinggoose/migrations - ecr_repo_secret: ECR_MIGRATIONS + repo: migrations - dockerfile: ./docker/realtime.Dockerfile - ghcr_image: ghcr.io/tradinggoose/realtime - ecr_repo_secret: ECR_REALTIME - outputs: - registry: ${{ steps.login-ecr.outputs.registry }} + repo: realtime steps: - name: Checkout code uses: actions/checkout@v4 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 with: - role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} - aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }} - - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + platforms: arm64 - name: Login to GHCR - if: github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to Docker Hub + if: github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx uses: useblacksmith/setup-docker-builder@v1 - name: Generate tags id: meta run: | - ECR_REGISTRY="${{ steps.login-ecr.outputs.registry }}" - ECR_REPO="${{ secrets[matrix.ecr_repo_secret] }}" - GHCR_IMAGE="${{ matrix.ghcr_image }}" - - # ECR tags (always build for ECR) - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - ECR_TAG="latest" - else - ECR_TAG="staging" + set -euo pipefail + + repo="${{ matrix.repo }}" + ghcr_image="ghcr.io/tradinggoose/${repo}" + sha_tag="${{ github.sha }}" + tags=("${ghcr_image}:${sha_tag}") + + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + dockerhub_image="docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${repo}" + tags+=("${ghcr_image}:latest") + tags+=("${dockerhub_image}:${sha_tag}") + tags+=("${dockerhub_image}:latest") fi - ECR_IMAGE="${ECR_REGISTRY}/${ECR_REPO}:${ECR_TAG}" - # Build tags list - TAGS="${ECR_IMAGE}" - - # Add GHCR tags only for main branch - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - GHCR_AMD64="${GHCR_IMAGE}:latest-amd64" - GHCR_SHA="${GHCR_IMAGE}:${{ github.sha }}-amd64" - TAGS="${TAGS},$GHCR_AMD64,$GHCR_SHA" - fi - - echo "tags=${TAGS}" >> $GITHUB_OUTPUT + tags_csv=$(IFS=,; printf '%s' "${tags[*]}") + echo "tags=${tags_csv}" >> "$GITHUB_OUTPUT" - name: Build and push images uses: useblacksmith/build-push-action@v2 with: context: . file: ${{ matrix.dockerfile }} - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} - provenance: false - sbom: false - - build-ghcr-arm64: - name: Build ARM64 (GHCR Only) - runs-on: linux-arm64-8-core - if: github.ref == 'refs/heads/main' - strategy: - fail-fast: false - matrix: - include: - - dockerfile: ./docker/app.Dockerfile - image: ghcr.io/tradinggoose/tradinggoose - - dockerfile: ./docker/db.Dockerfile - image: ghcr.io/tradinggoose/migrations - - dockerfile: ./docker/realtime.Dockerfile - image: ghcr.io/tradinggoose/realtime - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: useblacksmith/setup-docker-builder@v1 - - - name: Generate ARM64 tags - id: meta - run: | - IMAGE="${{ matrix.image }}" - echo "tags=${IMAGE}:latest-arm64,${IMAGE}:${{ github.sha }}-arm64" >> $GITHUB_OUTPUT - - - name: Build and push ARM64 to GHCR - uses: useblacksmith/build-push-action@v2 - with: - context: . - file: ${{ matrix.dockerfile }} - platforms: linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - provenance: false - sbom: false - - create-ghcr-manifests: - name: Create GHCR Manifests - runs-on: blacksmith-4vcpu-ubuntu-2404 - needs: [build-amd64, build-ghcr-arm64] - if: github.ref == 'refs/heads/main' - strategy: - matrix: - include: - - image: ghcr.io/tradinggoose/tradinggoose - - image: ghcr.io/tradinggoose/migrations - - image: ghcr.io/tradinggoose/realtime - - steps: - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Create and push manifests - run: | - IMAGE_BASE="${{ matrix.image }}" - - # Create latest manifest - docker manifest create "${IMAGE_BASE}:latest" \ - "${IMAGE_BASE}:latest-amd64" \ - "${IMAGE_BASE}:latest-arm64" - docker manifest push "${IMAGE_BASE}:latest" - - # Create SHA manifest - docker manifest create "${IMAGE_BASE}:${{ github.sha }}" \ - "${IMAGE_BASE}:${{ github.sha }}-amd64" \ - "${IMAGE_BASE}:${{ github.sha }}-arm64" - docker manifest push "${IMAGE_BASE}:${{ github.sha }}" \ No newline at end of file + provenance: true + sbom: true diff --git a/README.md b/README.md index 39e5afffa..7739e90d9 100644 --- a/README.md +++ b/README.md @@ -64,14 +64,18 @@ It is built for analytics, research, charting, monitoring, and workflow automati bun install ``` -#### 2. Start PostgreSQL database +#### 2. Start PostgreSQL database and Redis ``` docker run --name tradinggoose-db ` - -e POSTGRES_PASSWORD=postgres ` + -e POSTGRES_USER=tradinggoose ` + -e POSTGRES_PASSWORD= ` -e POSTGRES_DB=tradinggoose ` -p 5432:5432 ` -d pgvector/pgvector:pg17 + +docker run -d --name tradinggoose-redis -p 6379:6379 redis ``` + #### 3. Setup environment variables ``` cd apps/tradinggoose && cp .env.example .env @@ -90,6 +94,25 @@ cd ../.. bun run dev:full ``` +## Docker Compose + +If you use Docker Compose, copy `apps/tradinggoose/.env.example.docker` to +`apps/tradinggoose/.env` and set the required secrets before running the +compose manifests. The `.env` must include `POSTGRES_*`, +`NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_SOCKET_URL`, `BETTER_AUTH_SECRET`, +`ENCRYPTION_KEY`, `API_ENCRYPTION_KEY`, and `INTERNAL_API_SECRET`. The +`ENCRYPTION_KEY` value is shared by both the app and realtime containers, and +`API_ENCRYPTION_KEY` enables encrypted API-key storage in the app container. +`NEXT_PUBLIC_SOCKET_URL` should point at `http://localhost:3002` for local +Compose runs; production deployments must override it with a browser-reachable +public URL. The prod and Ollama compose files also require `IMAGE_TAG` and +`OLLAMA_IMAGE_TAG` respectively. + +``` +docker compose --env-file ./apps/tradinggoose/.env -f docker-compose.local.yml up +``` + + ## Contributing Pull requests are welcome. diff --git a/apps/docs/app/[lang]/[[...slug]]/page.tsx b/apps/docs/app/[lang]/[[...slug]]/page.tsx index 5ffcd97ba..e5cba1ee1 100644 --- a/apps/docs/app/[lang]/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/[[...slug]]/page.tsx @@ -9,12 +9,19 @@ import { StructuredData } from '@/components/structured-data' import { AccordionHashSync } from '@/components/ui/accordion-hash-sync' import { CodeBlock } from '@/components/ui/code-block' import { CopyPageButton } from '@/components/ui/copy-page-button' -import { i18n } from '@/lib/i18n' -import { humanizeSlug, supportedLanguages } from '@/lib/page-tree' +import { i18n, localizePathname, localizeUrl, stripLocaleFromPathname } from '@/lib/i18n' +import { humanizeSlug } from '@/lib/page-tree' import { source } from '@/lib/source' function toOpenGraphLocale(lang: string) { - return lang === 'en' ? 'en_US' : `${lang}_${lang.toUpperCase()}` + if (lang === 'en') return 'en_US' + if (lang === 'zh-CN') return 'zh_CN' + return `${lang}_${lang.toUpperCase()}` +} + +function toPublicDocsUrl(pathname: string) { + const { locale, pathname: strippedPathname } = stripLocaleFromPathname(pathname) + return localizeUrl('https://docs.tradinggoose.ai', locale, strippedPathname) } export default async function Page(props: { params: Promise<{ slug?: string[]; lang: string }> }) { @@ -31,6 +38,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l if (!page) notFound() + const publicPageUrl = toPublicDocsUrl(page.url) const MDX = page.data.body const neighbours = pageTree ? findNeighbour(pageTree, page.url) : null @@ -42,7 +50,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
{neighbours?.previous ? ( @@ -54,7 +62,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l {neighbours?.next ? ( {neighbours.next.name} @@ -119,7 +127,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l @@ -176,26 +184,23 @@ function generateBreadcrumbs(targetUrl: string, pageTitle: string, baseUrl: stri }, ] - const urlParts = targetUrl.split('/').filter(Boolean) + const { locale, pathname } = stripLocaleFromPathname(targetUrl) + const urlParts = pathname.split('/').filter(Boolean) let currentPath = '' urlParts.forEach((part, index) => { - if (index === 0 && supportedLanguages.includes(part as (typeof supportedLanguages)[number])) { - currentPath = `/${part}` - return - } - currentPath += `/${part}` + const localizedPath = localizePathname(locale, currentPath) if (index === urlParts.length - 1) { breadcrumbs.push({ name: pageTitle, - url: `${baseUrl}${targetUrl}`, + url: `${baseUrl}${localizedPath}`, }) } else { breadcrumbs.push({ name: humanizeSlug(part), - url: `${baseUrl}${currentPath}`, + url: `${baseUrl}${localizedPath}`, }) } }) @@ -215,6 +220,7 @@ export async function generateMetadata(props: { const baseUrl = 'https://docs.tradinggoose.ai' const defaultDescription = 'TradingGoose visual workflow builder for AI applications documentation' + const locale = params.lang as (typeof i18n.languages)[number] const pageTreeRecord = source.pageTree as Record const pageTree = @@ -225,18 +231,13 @@ export async function generateMetadata(props: { (slugSegments.length === 0 ? source.getPage(['index'], params.lang) : null) if (!page) notFound() - const fullUrl = `${baseUrl}${page.url}` - const canonicalPath = page.url.replace(`/${params.lang}`, '') || '/' + const { pathname: strippedPathname } = stripLocaleFromPathname(page.url) + const fullUrl = localizeUrl(baseUrl, locale, strippedPathname) const alternateLocales = i18n.languages - .filter((lang) => lang !== params.lang) + .filter((lang) => lang !== locale) .map(toOpenGraphLocale) const alternateLanguages = Object.fromEntries( - i18n.languages.map((lang) => [ - lang, - lang === i18n.defaultLanguage - ? `${baseUrl}${canonicalPath}` - : `${baseUrl}/${lang}${canonicalPath}`, - ]) + i18n.languages.map((lang) => [lang, localizeUrl(baseUrl, lang, strippedPathname)]) ) return { @@ -262,7 +263,7 @@ export async function generateMetadata(props: { url: fullUrl, siteName: 'TradingGoose Documentation', type: 'article', - locale: toOpenGraphLocale(params.lang), + locale: toOpenGraphLocale(locale), ...(alternateLocales.length > 0 ? { alternateLocale: alternateLocales, @@ -289,7 +290,7 @@ export async function generateMetadata(props: { alternates: { canonical: fullUrl, languages: { - 'x-default': `${baseUrl}${canonicalPath}`, + 'x-default': localizeUrl(baseUrl, i18n.defaultLanguage, strippedPathname), ...alternateLanguages, }, }, diff --git a/apps/docs/app/[lang]/layout.tsx b/apps/docs/app/[lang]/layout.tsx index 02c79e589..12a49c720 100644 --- a/apps/docs/app/[lang]/layout.tsx +++ b/apps/docs/app/[lang]/layout.tsx @@ -7,7 +7,7 @@ import Image from 'next/image' import { notFound } from 'next/navigation' import { Analytics } from '@vercel/analytics/next' import '../global.css' -import { i18n } from '@/lib/i18n' +import { i18n, localizePathname } from '@/lib/i18n' import { source } from '@/lib/source' const inter = Inter({ @@ -25,7 +25,7 @@ const { provider } = defineI18nUI(i18n, { en: { displayName: 'English', }, - zh: { + 'zh-CN': { displayName: '简体中文', }, es: { @@ -108,7 +108,7 @@ export default async function Layout({ children, params }: LayoutProps) { }} nav={{ title: 'Documentations', - url: `/${locale}`, + url: localizePathname(locale, '/'), logo: (
lang !== i18n.defaultLanguage) + try { const pages = source.getPages().filter((page) => { if (!page || !page.data || !page.url) return false const pathParts = page.url.split('/').filter(Boolean) - const hasLangPrefix = pathParts[0] && ['es', 'fr', 'de', 'ja', 'zh'].includes(pathParts[0]) + const hasLangPrefix = + pathParts[0] && + localizedLanguages.includes(pathParts[0] as (typeof localizedLanguages)[number]) return !hasLangPrefix }) diff --git a/apps/docs/app/sitemap.xml/route.ts b/apps/docs/app/sitemap.xml/route.ts index e43c65a25..6c624cb66 100644 --- a/apps/docs/app/sitemap.xml/route.ts +++ b/apps/docs/app/sitemap.xml/route.ts @@ -1,4 +1,4 @@ -import { i18n } from '@/lib/i18n' +import { i18n, localizePathname } from '@/lib/i18n' import { source } from '@/lib/source' export const revalidate = false @@ -8,8 +8,20 @@ export async function GET() { const allPages = source.getPages() + const stripLanguagePrefix = (url: string) => { + const segments = url.split('/').filter(Boolean) + const firstSegment = segments[0] + + if (firstSegment && i18n.languages.includes(firstSegment as (typeof i18n.languages)[number])) { + const pathname = `/${segments.slice(1).join('/')}` + return pathname === '/' ? '/' : pathname.replace(/\/+$/, '') + } + + return url || '/' + } + const getPriority = (url: string): string => { - if (url === '/en' || url === '/') return '1.0' + if (url === '/' || url === '/index') return '1.0' if (url === '/getting-started') return '0.9' if (url.match(/^\/[^/]+$/)) return '0.8' if (url.includes('/sdks/') || url.includes('/tools/')) return '0.7' @@ -18,13 +30,10 @@ export async function GET() { const urls = allPages .flatMap((page) => { - const urlWithoutLang = page.url.replace(/^\/[a-z]{2}\//, '/') + const urlWithoutLang = stripLanguagePrefix(page.url) return i18n.languages.map((lang) => { - const url = - lang === i18n.defaultLanguage - ? `${baseUrl}${urlWithoutLang}` - : `${baseUrl}/${lang}${urlWithoutLang}` + const url = `${baseUrl}${localizePathname(lang, urlWithoutLang)}` return ` ${url} @@ -53,10 +62,7 @@ ${urls} function generateAlternateLinks(baseUrl: string, urlWithoutLang: string): string { return i18n.languages .map((lang) => { - const url = - lang === i18n.defaultLanguage - ? `${baseUrl}${urlWithoutLang}` - : `${baseUrl}/${lang}${urlWithoutLang}` + const url = `${baseUrl}${localizePathname(lang, urlWithoutLang)}` return ` ` }) .join('\n') diff --git a/apps/docs/components/structured-data.tsx b/apps/docs/components/structured-data.tsx index 42d4290a3..8bea24041 100644 --- a/apps/docs/components/structured-data.tsx +++ b/apps/docs/components/structured-data.tsx @@ -88,7 +88,7 @@ export function StructuredData({ }, 'query-input': 'required name=search_term_string', }, - inLanguage: ['en', 'es', 'fr', 'de', 'ja', 'zh'], + inLanguage: ['en', 'es', 'zh-CN'], } const faqStructuredData = title.toLowerCase().includes('faq') && { diff --git a/apps/docs/components/ui/language-dropdown.tsx b/apps/docs/components/ui/language-dropdown.tsx index ed7695227..36b2416d0 100644 --- a/apps/docs/components/ui/language-dropdown.tsx +++ b/apps/docs/components/ui/language-dropdown.tsx @@ -3,14 +3,12 @@ import { useEffect, useState } from 'react' import { Check, ChevronRight } from 'lucide-react' import { useParams, usePathname, useRouter } from 'next/navigation' +import { localizePathname, stripLocaleFromPathname } from '@/lib/i18n' const languages = { en: { name: 'English', flag: '🇺🇸' }, es: { name: 'Español', flag: '🇪🇸' }, - fr: { name: 'Français', flag: '🇫🇷' }, - de: { name: 'Deutsch', flag: '🇩🇪' }, - ja: { name: '日本語', flag: '🇯🇵' }, - zh: { name: '简体中文', flag: '🇨🇳' }, + 'zh-CN': { name: '简体中文', flag: '🇨🇳' }, } export function LanguageDropdown() { @@ -46,20 +44,8 @@ export function LanguageDropdown() { setIsOpen(false) - const segments = pathname.split('/').filter(Boolean) - - if (segments[0] && Object.keys(languages).includes(segments[0])) { - segments.shift() - } - - let newPath = '' - if (locale === 'en') { - newPath = segments.length > 0 ? `/${segments.join('/')}` : '' - } else { - newPath = `/${locale}${segments.length > 0 ? `/${segments.join('/')}` : ''}` - } - - router.push(newPath) + const { pathname: normalizedPathname } = stripLocaleFromPathname(pathname || '/') + router.push(localizePathname(locale as keyof typeof languages, normalizedPathname)) } useEffect(() => { diff --git a/apps/docs/i18n.json b/apps/docs/i18n.json index 143377351..3421b0ed0 100644 --- a/apps/docs/i18n.json +++ b/apps/docs/i18n.json @@ -4,7 +4,10 @@ "engineId": "eng_RF7UdAqExlX8Wc62", "locale": { "source": "en", - "targets": [] + "targets": [ + "es", + "zh-CN" + ] }, "buckets": { "mdx": { @@ -15,4 +18,4 @@ ] } } -} \ No newline at end of file +} diff --git a/apps/docs/lib/i18n.test.ts b/apps/docs/lib/i18n.test.ts new file mode 100644 index 000000000..380b4a7c5 --- /dev/null +++ b/apps/docs/lib/i18n.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { localizePathname, stripLocaleFromPathname } from './i18n' + +describe('docs i18n helpers', () => { + it('maps the Chinese locale to the public zh path segment', () => { + expect(localizePathname('zh-CN', '/getting-started')).toBe('/zh/getting-started') + expect(localizePathname('zh-CN', '/getting-started')).not.toContain('/zh-CN') + }) + + it('strips the public zh segment back to the internal zh-CN locale', () => { + expect(stripLocaleFromPathname('/zh/getting-started')).toEqual({ + locale: 'zh-CN', + pathname: '/getting-started', + }) + }) +}) diff --git a/apps/docs/lib/i18n.ts b/apps/docs/lib/i18n.ts index 75d9c3b1b..7117e1079 100644 --- a/apps/docs/lib/i18n.ts +++ b/apps/docs/lib/i18n.ts @@ -2,7 +2,59 @@ import { defineI18n } from 'fumadocs-core/i18n' export const i18n = defineI18n({ defaultLanguage: 'en', - languages: ['en', 'zh', 'es'], + languages: ['en', 'es', 'zh-CN'], hideLocale: 'default-locale', parser: 'dir', }) + +type DocsLocale = (typeof i18n.languages)[number] + +const PUBLIC_LOCALE_PATH_SEGMENTS: Record = { + en: 'en', + es: 'es', + 'zh-CN': 'zh', +} + +export function getPublicLocalePathSegment(locale: DocsLocale) { + return PUBLIC_LOCALE_PATH_SEGMENTS[locale] +} + +export function stripLocaleFromPathname(pathname: string): { locale: DocsLocale; pathname: string } { + const segments = pathname.split('/').filter(Boolean) + const firstSegment = segments[0] + + if (firstSegment) { + const locale = i18n.languages.find( + (candidate) => + candidate === firstSegment || getPublicLocalePathSegment(candidate) === firstSegment + ) + + if (locale) { + const stripped = `/${segments.slice(1).join('/')}`.replace(/\/+$/, '') + return { + locale, + pathname: stripped || '/', + } + } + } + + return { + locale: i18n.defaultLanguage, + pathname: pathname || '/', + } +} + +export function localizePathname(locale: DocsLocale, pathname: string) { + const normalized = pathname === '/' ? '/' : pathname.replace(/\/+$/, '') + const localeSegment = getPublicLocalePathSegment(locale) + + if (locale === i18n.defaultLanguage) { + return normalized + } + + return normalized === '/' ? `/${localeSegment}` : `/${localeSegment}${normalized}` +} + +export function localizeUrl(baseUrl: string, locale: DocsLocale, pathname: string) { + return `${baseUrl}${localizePathname(locale, pathname)}` +} diff --git a/apps/docs/proxy.test.ts b/apps/docs/proxy.test.ts new file mode 100644 index 000000000..e905907e3 --- /dev/null +++ b/apps/docs/proxy.test.ts @@ -0,0 +1,35 @@ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockI18nMiddleware } = vi.hoisted(() => ({ + mockI18nMiddleware: vi.fn(() => new Response('ok')), +})) + +vi.mock('fumadocs-core/i18n/middleware', () => ({ + createI18nMiddleware: vi.fn(() => mockI18nMiddleware), +})) + +describe('docs proxy', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rewrites the public zh path to the internal zh-CN locale segment', async () => { + const { proxy } = await import('./proxy') + const response = proxy(new NextRequest('https://docs.tradinggoose.ai/zh/getting-started')) + + expect(response.status).toBe(200) + expect(response.headers.get('x-middleware-rewrite')).toBe( + 'https://docs.tradinggoose.ai/zh-CN/getting-started' + ) + expect(mockI18nMiddleware).not.toHaveBeenCalled() + }) + + it('rejects the old zh-CN public prefix', async () => { + const { proxy } = await import('./proxy') + const response = proxy(new NextRequest('https://docs.tradinggoose.ai/zh-CN/getting-started')) + + expect(response.status).toBe(404) + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + }) +}) diff --git a/apps/docs/proxy.ts b/apps/docs/proxy.ts index d9f6a875a..ab32f48d8 100644 --- a/apps/docs/proxy.ts +++ b/apps/docs/proxy.ts @@ -1,8 +1,28 @@ import { createI18nMiddleware } from 'fumadocs-core/i18n/middleware' +import { NextRequest, NextResponse } from 'next/server' import { i18n } from '@/lib/i18n' const i18nProxy = createI18nMiddleware(i18n) -export { i18nProxy as proxy } + +export function proxy(request: NextRequest, event?: Parameters[1]) { + const { pathname } = request.nextUrl + + if (pathname === '/zh-CN' || pathname.startsWith('/zh-CN/')) { + return new NextResponse(null, { status: 404 }) + } + + if (pathname === '/zh' || pathname.startsWith('/zh/')) { + const internalUrl = new URL(request.url) + internalUrl.pathname = pathname === '/zh' ? '/zh-CN' : pathname.replace(/^\/zh(?=\/|$)/, '/zh-CN') + return NextResponse.rewrite(internalUrl, { + request: { + headers: request.headers, + }, + }) + } + + return i18nProxy(request, event as Parameters[1]) +} export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon|static|robots.txt|sitemap.xml|llms.txt).*)'], diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt index 1931961fd..c038df8ad 100644 --- a/apps/docs/public/llms.txt +++ b/apps/docs/public/llms.txt @@ -31,7 +31,7 @@ Here are the key areas covered in our documentation: - Framework: Fumadocs (Next.js-based documentation platform) - Content: MDX files with interactive examples -- Languages: English (primary), French, Chinese +- Languages: English (primary), Spanish, Simplified Chinese - Search: AI-powered search and assistance available ## Complete Documentation @@ -50,4 +50,4 @@ For the complete documentation with interactive examples and visual workflow bui --- -Last updated: 2025-09-15 \ No newline at end of file +Last updated: 2025-09-15 diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 750392aa1..be952b911 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -43,6 +43,10 @@ ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx" ] } diff --git a/apps/docs/vitest.config.mts b/apps/docs/vitest.config.mts new file mode 100644 index 000000000..3f7fe204a --- /dev/null +++ b/apps/docs/vitest.config.mts @@ -0,0 +1,12 @@ +import { fileURLToPath } from 'node:url' + +export default { + resolve: { + alias: { + '@': fileURLToPath(new URL('./', import.meta.url)), + }, + }, + test: { + environment: 'node', + }, +} diff --git a/apps/tradinggoose/.env.example b/apps/tradinggoose/.env.example index 58959e72f..6e0e3fa37 100644 --- a/apps/tradinggoose/.env.example +++ b/apps/tradinggoose/.env.example @@ -26,7 +26,7 @@ # Required: PostgreSQL connection string used by the Next app, socket server, # and local Drizzle commands. -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/tradinggoose" +DATABASE_URL="postgresql://tradinggoose:replace-with-password@localhost:5432/tradinggoose" # Required: public browser URL for the Studio app. # This should match the URL you actually open in your browser. @@ -37,20 +37,20 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000" BETTER_AUTH_URL="http://localhost:3000" # Required: Better Auth signing secret. -# Generate with: openssl rand -hex 32 +# Generate a secure 64-character hex secret. BETTER_AUTH_SECRET="replace-with-64-hex-characters" # Required: default secret encryption key for stored secrets. -# Generate with: openssl rand -hex 32 +# Generate a secure 64-character hex secret. ENCRYPTION_KEY="replace-with-64-hex-characters" # Recommended: dedicated encryption key for stored API credentials. -# Generate with: openssl rand -hex 32 +# Generate a secure 64-character hex secret. API_ENCRYPTION_KEY="replace-with-64-hex-characters" # Required: internal server-to-server auth secret used by app routes, sockets, # cron endpoints, and other internal calls. -# Generate with: openssl rand -hex 32 +# Generate a secure 64-character hex secret. INTERNAL_API_SECRET="replace-with-64-hex-characters" # Recommended: Redis for cache, locks, idempotency, and rate limiting. @@ -130,7 +130,7 @@ NEXT_PUBLIC_SSO_ENABLED="false" # TRIGGER_SECRET_KEY="" # Optional: secret expected by cron/internal scheduled requests. -# Generate with: openssl rand -hex 32 +# Generate a secure 64-character hex secret. # CRON_SECRET="replace-with-64-hex-characters" ############################################################################### diff --git a/apps/tradinggoose/.env.example.docker b/apps/tradinggoose/.env.example.docker new file mode 100644 index 000000000..21191cd1e --- /dev/null +++ b/apps/tradinggoose/.env.example.docker @@ -0,0 +1,51 @@ +# Database (Required) +DATABASE_URL="postgres://postgres:postgres@db:5432/tradinggoose" + +# PostgreSQL Port (Optional) - defaults to 5432 if not specified +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=tradinggoose +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 + +# Authentication (Required) +# Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation +BETTER_AUTH_SECRET=generate-the-secret +BETTER_AUTH_URL=http://localhost:3000 + +# NextJS (Required) +NEXT_PUBLIC_APP_URL=http://localhost:3000 +# Browser-accessible socket URL for local Compose runs; override it for production. +NEXT_PUBLIC_SOCKET_URL=http://localhost:3002 + +# Docker image tags (Required for compose manifests that pull published images) +IMAGE_TAG=latest +OLLAMA_IMAGE_TAG=latest + +# Security (Required) +# Use `openssl rand -hex 32` to generate, used to encrypt environment variables +ENCRYPTION_KEY=generate-the-key +# Use `openssl rand -hex 32` to generate, used to encrypt internal api routes +INTERNAL_API_SECRET=generate-the-secret + +# Email Provider (Optional) +# RESEND_API_KEY= +# Uncomment and add your key from https://resend.com to send actual emails +# If left commented out, emails will be logged to console instead + +# Local AI Models (Optional) +# URL for local Ollama server - uncomment if using local models +# OLLAMA_URL=http://localhost:11434 + +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= + +# ALPACA_CLIENT_ID= +# ALPACA_CLIENT_SECRET= + +# MARKET_API_URL=https://market.tradinggoose.ai +# MARKET_API_KEY= + +# COPILOT_API_KEY= + +REDIS_URL=redis://redis:6379 diff --git a/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx b/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx index 3e2fa7cfa..8199509fa 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 { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' export function AuthWaitlistNote() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) + 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.test.tsx b/apps/tradinggoose/app/(auth)/components/social-login-buttons.test.tsx new file mode 100644 index 000000000..e037e08f4 --- /dev/null +++ b/apps/tradinggoose/app/(auth)/components/social-login-buttons.test.tsx @@ -0,0 +1,99 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' +import { SocialLoginButtons } from './social-login-buttons' + +const { useLocaleMock } = vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'es'), +})) + +const { mockClient } = vi.hoisted(() => ({ + mockClient: { + signIn: { + social: vi.fn(), + }, + }, +})) + +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + +vi.mock('@/lib/auth-client', () => ({ + client: mockClient, +})) + +vi.mock('@/app/fonts/inter', () => ({ + inter: { className: '' }, +})) + +describe('SocialLoginButtons', () => { + let container: HTMLDivElement + let root: Root + + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + beforeEach(() => { + vi.clearAllMocks() + 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('shows a localized error alert when OAuth sign-in fails and resets loading state', async () => { + const copy = getPublicCopy('es') + + mockClient.signIn.social.mockRejectedValueOnce(new Error('popup closed')) + + await act(async () => { + root.render( + + ) + }) + + const githubButton = container.querySelector('button') + expect(githubButton).not.toBeNull() + if (!githubButton) { + throw new Error('Expected GitHub sign-in button') + } + + expect(githubButton.textContent).toContain(copy.auth.social.github) + + await act(async () => { + githubButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + + expect(mockClient.signIn.social).toHaveBeenCalledWith({ + provider: 'github', + callbackURL: '/workspace', + }) + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + copy.auth.error.default.description + ) + expect(container.querySelector('button')?.textContent).toContain(copy.auth.social.github) + expect(container.querySelector('button')).not.toBeDisabled() + }) +}) diff --git a/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx b/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx index 602326f92..96229ec22 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 { getPublicCopy } from '@/i18n/public-copy' +import { localizePathname, type LocaleCode } from '@/i18n/utils' + +const logger = createLogger('SocialLoginButtons') interface SocialLoginButtonsProps { githubAvailable: boolean @@ -17,13 +24,18 @@ 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 = getPublicCopy(locale) + const socialCopy = copy.auth.social + const resolvedCallbackURL = callbackURL ?? localizePathname(locale, '/workspace') // Set mounted state to true on client-side useEffect(() => { @@ -37,20 +49,12 @@ export function SocialLoginButtons({ 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..291fe3192 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 { useRouter as useI18nRouter } from '@/i18n/navigation' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' interface SSOLoginButtonProps { callbackURL?: string @@ -19,7 +22,9 @@ export function SSOLoginButton({ className, variant = 'outline', }: SSOLoginButtonProps) { - const router = useRouter() + const router = useI18nRouter() + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) { return null @@ -42,7 +47,7 @@ export function SSOLoginButton({ variant={variant === 'outline' ? 'outline' : undefined} className={cn(variant === 'outline' ? outlineBtnClasses : primaryBtnClasses, className)} > - Sign in with SSO + {copy.auth.common.signInWithSso} ) } diff --git a/apps/tradinggoose/app/(auth)/error/page.tsx b/apps/tradinggoose/app/(auth)/error/page.tsx index 512a10ce4..34194c881 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 { getAuthErrorActionLabel, 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 { getPublicCopy } from '@/i18n/public-copy' +import { defaultLocale, isLocaleCode, localizeHref, type LocaleCode } from '@/i18n/utils' export const dynamic = 'force-dynamic' @@ -22,14 +25,28 @@ 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 resolvedLocale = await getLocale() + const locale: LocaleCode = isLocaleCode(resolvedLocale) ? resolvedLocale : defaultLocale + const copy = getPublicCopy(locale) + const errorCopy = copy.auth.error + const { code, content } = getAuthErrorContent(copy, error, errorDescription) const brand = getBrandConfig() const supportEmail = brand.supportEmail + const primaryAction = { + ...content.primaryAction, + href: localizeHref(locale, content.primaryAction.href), + label: getAuthErrorActionLabel(copy, content.primaryAction.href, content.primaryAction.label), + } + const secondaryAction = { + ...content.secondaryAction, + href: localizeHref(locale, content.secondaryAction.href), + label: getAuthErrorActionLabel(copy, content.secondaryAction.href, content.secondaryAction.label), + } return (
@@ -39,7 +56,7 @@ export default async function AuthErrorPage({

- Error code + {errorCopy.codeLabel}

{error} @@ -48,22 +65,22 @@ export default async function AuthErrorPage({ ) : 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)/login/login-form.test.tsx b/apps/tradinggoose/app/(auth)/login/login-form.test.tsx new file mode 100644 index 000000000..220dddccd --- /dev/null +++ b/apps/tradinggoose/app/(auth)/login/login-form.test.tsx @@ -0,0 +1,149 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' +import LoginPage from './login-form' + +const { useLocaleMock, useSearchParamsMock, useRouterPushMock, getEnvMock, isTruthyMock } = + vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'zh-CN'), + useSearchParamsMock: vi.fn(() => new URLSearchParams('')), + useRouterPushMock: vi.fn(), + getEnvMock: vi.fn(), + isTruthyMock: vi.fn(() => false), + })) + +const { mockClient } = vi.hoisted(() => ({ + mockClient: { + signIn: { + email: vi.fn(), + social: vi.fn(), + sso: vi.fn(), + }, + signUp: { + email: vi.fn(), + }, + emailOtp: { + sendVerificationOtp: vi.fn(), + }, + }, +})) + +const localizeHref = (href: string, locale: string) => { + if (!href.startsWith('/')) return href + const localePrefix = locale === 'zh-CN' ? '/zh' : `/${locale}` + if (href.startsWith(localePrefix)) return href + return locale === 'en' ? href : `${localePrefix}${href}` +} + +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + +vi.mock('next/navigation', () => ({ + useSearchParams: useSearchParamsMock, +})) + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { children?: React.ReactNode; href: string }) => { + const locale = useLocaleMock() + + return ( + + {children} + + ) + }, + useRouter: () => ({ + push: useRouterPushMock, + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + }), +})) + +vi.mock('@/lib/env', () => ({ + env: { + NODE_ENV: 'test', + EMAIL_VERIFICATION_ENABLED: false, + }, + getEnv: getEnvMock, + isTruthy: isTruthyMock, +})) + +vi.mock('@/lib/auth-client', () => ({ + client: mockClient, +})) + +vi.mock('@/app/fonts/inter', () => ({ + inter: { className: '' }, +})) + +vi.mock('@/app/fonts/soehne/soehne', () => ({ + soehne: { className: '' }, +})) + +describe('login screen localization', () => { + let container: HTMLDivElement + let root: Root + + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + beforeEach(() => { + vi.clearAllMocks() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + useLocaleMock.mockReturnValue('zh-CN') + useSearchParamsMock.mockReturnValue(new URLSearchParams('')) + getEnvMock.mockReturnValue(undefined) + isTruthyMock.mockReturnValue(false) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.restoreAllMocks() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + it('renders translated login CTA text and locale-prefixed signup href', async () => { + const copy = getPublicCopy('zh-CN') + + await act(async () => { + root.render( + + ) + }) + + expect(container.querySelector('h1')?.textContent).toBe(copy.auth.login.title) + expect(container.querySelector('button[type="submit"]')?.textContent).toBe( + copy.auth.login.submit + ) + + const signupLink = container.querySelector('a[href="/zh/signup"]') + expect(signupLink?.textContent).toBe(copy.registration.open.auth) + expect(signupLink?.getAttribute('href')).toBe('/zh/signup') + }) +}) diff --git a/apps/tradinggoose/app/(auth)/login/login-form.tsx b/apps/tradinggoose/app/(auth)/login/login-form.tsx index 372b54b49..7440b2c06 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, @@ -19,13 +19,12 @@ import { handleAuthError } from '@/lib/auth/auth-error-handler' 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 { getAuthRegistrationLabel, getPublicCopy } from '@/i18n/public-copy' +import { localizePathname, 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,31 +33,30 @@ 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 => { @@ -79,16 +77,22 @@ const validateCallbackUrl = (url: string): boolean => { } } -const validatePassword = (passwordValue: string): string[] => { +const validatePassword = ( + passwordValue: string, + messages: { + required: string + empty: 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 +111,12 @@ export default function LoginPage({ registrationMode: RegistrationMode }) { const router = useRouter() + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) + const loginCopy = copy.auth.login + const commonCopy = copy.auth.common + const authRegistrationLabel = getAuthRegistrationLabel(copy, registrationMode) + const defaultCallbackUrl = localizePathname(locale, '/workspace') const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) const [showPassword, setShowPassword] = useState(false) @@ -116,7 +126,7 @@ 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(defaultCallbackUrl) const [isInviteFlow, setIsInviteFlow] = useState(false) const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) @@ -164,7 +174,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 +186,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 +202,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,7 +222,7 @@ export default function LoginPage({ } try { - const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' + const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : defaultCallbackUrl const result = await client.signIn.email( { @@ -217,15 +239,11 @@ export default function LoginPage({ (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')) { @@ -235,41 +253,37 @@ export default function LoginPage({ 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.') + errorMessage.push(loginCopy.errors.emailSignInDisabled) } else if ( ctx.error.code?.includes('INVALID_CREDENTIALS') || ctx.error.message?.includes('invalid password') ) { - errorMessage.push('Invalid email or password. Please try again.') + errorMessage.push(loginCopy.errors.invalidCredentials) } else if ( ctx.error.code?.includes('USER_NOT_FOUND') || ctx.error.message?.includes('not found') ) { - errorMessage.push('No account found with this email. Please sign up first.') + errorMessage.push(loginCopy.errors.noAccount) } else if (ctx.error.code?.includes('MISSING_CREDENTIALS')) { - errorMessage.push('Please enter both email and password.') + errorMessage.push(loginCopy.errors.missingCredentials) } else if (ctx.error.code?.includes('EMAIL_PASSWORD_DISABLED')) { - errorMessage.push('Email and password login is disabled.') + errorMessage.push(loginCopy.errors.emailPasswordDisabled) } else if (ctx.error.code?.includes('FAILED_TO_CREATE_SESSION')) { - errorMessage.push('Failed to create session. Please try again later.') + errorMessage.push(loginCopy.errors.failedToCreateSession) } else if (ctx.error.code?.includes('too many attempts')) { - errorMessage.push( - 'Too many login attempts. Please try again later or reset your password.' - ) + errorMessage.push(loginCopy.errors.tooManyAttempts) } else if (ctx.error.code?.includes('account locked')) { - errorMessage.push( - 'Your account has been locked for security. Please reset your password.' - ) + errorMessage.push(loginCopy.errors.accountLocked) } else if (ctx.error.code?.includes('network')) { - errorMessage.push('Network error. Please check your connection and try again.') + errorMessage.push(loginCopy.errors.network) } 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.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 +293,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 @@ -309,7 +317,7 @@ export default function LoginPage({ if (!forgotPasswordEmail) { setResetStatus({ type: 'error', - message: 'Please enter your email address', + message: loginCopy.resetDialog.emailRequired, }) return } @@ -318,7 +326,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 +342,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 +363,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 +376,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 +394,13 @@ export default function LoginPage({
- +
- +
@@ -448,7 +443,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 +457,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 +473,7 @@ export default function LoginPage({
@@ -490,7 +485,7 @@ export default function LoginPage({
- Or continue with + {loginCopy.divider}
@@ -511,7 +506,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 +543,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 +584,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..6aa9dcd11 100644 --- a/apps/tradinggoose/app/(auth)/reset-password/page.tsx +++ b/apps/tradinggoose/app/(auth)/reset-password/page.tsx @@ -1,9 +1,9 @@ 'use client' import { Suspense, useEffect, useState } from 'react' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' +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 { inter } from '@/app/fonts/inter' 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..e9d77492c 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' @@ -17,6 +17,9 @@ import { type RegistrationMode, } from '@/lib/registration/shared' import { cn } from '@/lib/utils' +import { Link, useRouter } from '@/i18n/navigation' +import { getPublicCopy } from '@/i18n/public-copy' +import { localizePathname, 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' @@ -26,52 +29,37 @@ import { inter } from '@/app/fonts/inter' const logger = createLogger('SignupForm') 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 +77,11 @@ function SignupFormContent({ registrationMode: RegistrationMode }) { const router = useRouter() + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) + const commonCopy = copy.auth.common + const signupCopy = copy.auth.signup + const defaultCallbackUrl = localizePathname(locale, '/workspace') const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() const [isLoading, setIsLoading] = useState(false) @@ -135,24 +128,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 +154,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 +197,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 +225,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 +260,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 +277,40 @@ function SignupFormContent({ { onError: (ctx) => { logger.error('Signup error:', ctx.error) - const errorMessage: string[] = ['Failed to create account'] + const errorMessage: string[] = [signupCopy.errors.failedToCreateAccount] if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) + 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') ) { if (ctx.error.message?.includes(REGISTRATION_DISABLED_MESSAGE)) { - errorMessage.push(REGISTRATION_DISABLED_MESSAGE) + errorMessage.push(signupCopy.errors.emailSignupDisabled) } else if (ctx.error.message?.includes(REGISTRATION_WAITLIST_MESSAGE)) { - errorMessage.push( - 'This email is not approved for signup yet. Join the waitlist first.' - ) + 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.') + 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.') + 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.') + 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.') + 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.') + errorMessage.push(signupCopy.errors.rateLimit) setPasswordErrors(errorMessage) setShowValidationError(true) } else { @@ -370,12 +365,12 @@ function SignupFormContent({ return ( <> @@ -385,16 +380,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 +481,7 @@ function SignupFormContent({
@@ -497,7 +492,7 @@ function SignupFormContent({
- Or continue with + {signupCopy.divider}
@@ -508,46 +503,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}
diff --git a/apps/tradinggoose/app/(auth)/signup/signup-page.test.tsx b/apps/tradinggoose/app/(auth)/signup/signup-page.test.tsx new file mode 100644 index 000000000..0def58aa0 --- /dev/null +++ b/apps/tradinggoose/app/(auth)/signup/signup-page.test.tsx @@ -0,0 +1,164 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' +import SignupPage from './page' + +const { useLocaleMock, useSearchParamsMock, useRouterPushMock, getEnvMock, isTruthyMock } = + vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'zh-CN'), + useSearchParamsMock: vi.fn(() => new URLSearchParams('')), + useRouterPushMock: vi.fn(), + getEnvMock: vi.fn(), + isTruthyMock: vi.fn(() => false), + })) + +const { mockClient, mockRefetchSession } = vi.hoisted(() => ({ + mockClient: { + signIn: { + email: vi.fn(), + social: vi.fn(), + sso: vi.fn(), + }, + signUp: { + email: vi.fn(), + }, + emailOtp: { + sendVerificationOtp: vi.fn(), + }, + }, + mockRefetchSession: vi.fn(), +})) + +const localizeHref = (href: string, locale: string) => { + if (!href.startsWith('/')) return href + const localePrefix = locale === 'zh-CN' ? '/zh' : `/${locale}` + if (href.startsWith(localePrefix)) return href + return locale === 'en' ? href : `${localePrefix}${href}` +} + +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + +vi.mock('next-intl/server', () => ({ + getLocale: vi.fn(async () => 'zh-CN'), +})) + +vi.mock('next/navigation', () => ({ + useSearchParams: useSearchParamsMock, +})) + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { children?: React.ReactNode; href: string }) => { + const locale = useLocaleMock() + + return ( + + {children} + + ) + }, + useRouter: () => ({ + push: useRouterPushMock, + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + }), +})) + +vi.mock('@/lib/env', () => ({ + env: { + NODE_ENV: 'test', + EMAIL_VERIFICATION_ENABLED: false, + }, + getEnv: getEnvMock, + isTruthy: isTruthyMock, +})) + +vi.mock('@/lib/auth-client', () => ({ + client: mockClient, + useSession: () => ({ + refetch: mockRefetchSession, + }), +})) + +vi.mock('@/app/(auth)/components/oauth-provider-checker', () => ({ + getOAuthProviderStatus: vi.fn(async () => ({ + githubAvailable: false, + googleAvailable: false, + isProduction: false, + })), +})) + +vi.mock('@/lib/registration/service', () => ({ + getRegistrationModeForRender: vi.fn(async () => 'open'), +})) + +vi.mock('@/app/fonts/inter', () => ({ + inter: { className: '' }, +})) + +vi.mock('@/app/fonts/soehne/soehne', () => ({ + soehne: { className: '' }, +})) + +describe('signup screen localization', () => { + let container: HTMLDivElement + let root: Root + + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + beforeEach(() => { + vi.clearAllMocks() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + useLocaleMock.mockReturnValue('zh-CN') + useSearchParamsMock.mockReturnValue(new URLSearchParams('')) + getEnvMock.mockReturnValue(undefined) + isTruthyMock.mockReturnValue(false) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.restoreAllMocks() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + it('renders translated signup CTA text and locale-prefixed login href', async () => { + const copy = getPublicCopy('zh-CN') + + const element = await SignupPage({ searchParams: Promise.resolve({}) }) + + await act(async () => { + root.render(element as React.ReactElement) + }) + + expect(container.querySelector('h1')?.textContent).toBe(copy.auth.signup.title) + expect(container.querySelector('button[type="submit"]')?.textContent).toBe( + copy.auth.signup.submit + ) + + const loginLink = container.querySelector('a[href="/zh/login"]') + expect(loginLink?.textContent).toBe(copy.auth.common.signIn) + expect(loginLink?.getAttribute('href')).toBe('/zh/login') + }) +}) diff --git a/apps/tradinggoose/app/(auth)/sso/page.tsx b/apps/tradinggoose/app/(auth)/sso/page.tsx index f61bbfbdd..69eba89bf 100644 --- a/apps/tradinggoose/app/(auth)/sso/page.tsx +++ b/apps/tradinggoose/app/(auth)/sso/page.tsx @@ -1,13 +1,16 @@ -import { redirect } from 'next/navigation' +import { getLocale } from 'next-intl/server' import { getEnv, isTruthy } from '@/lib/env' import { getRegistrationModeForRender } from '@/lib/registration/service' +import { redirect } from '@/i18n/navigation' +import { type LocaleCode } from '@/i18n/utils' import SSOForm from './sso-form' export const dynamic = 'force-dynamic' export default async function SSOPage() { if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) { - redirect('/login') + const locale = (await getLocale()) as LocaleCode + redirect({ href: '/login', locale }) } const registrationMode = await getRegistrationModeForRender() diff --git a/apps/tradinggoose/app/(auth)/sso/sso-form.tsx b/apps/tradinggoose/app/(auth)/sso/sso-form.tsx index 3c555ec14..b71e54190 100644 --- a/apps/tradinggoose/app/(auth)/sso/sso-form.tsx +++ b/apps/tradinggoose/app/(auth)/sso/sso-form.tsx @@ -1,37 +1,42 @@ 'use client' import { 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 { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { client } from '@/lib/auth-client' import { quickValidateEmail } from '@/lib/email/validation' 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 { cn } from '@/lib/utils' +import { Link, useRouter } from '@/i18n/navigation' +import { getAuthRegistrationLabel, getPublicCopy } from '@/i18n/public-copy' +import { localizePathname, type LocaleCode } from '@/i18n/utils' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { AuthWaitlistNote } from '@/app/(auth)/components/auth-waitlist-note' import { inter } from '@/app/fonts/inter' const logger = createLogger('SSOForm') -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 @@ -57,6 +62,11 @@ const validateCallbackUrl = (url: string): boolean => { export default function SSOForm({ registrationMode }: { registrationMode: RegistrationMode }) { const router = useRouter() + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) + const commonCopy = copy.auth.common + const ssoCopy = copy.auth.sso + const defaultCallbackUrl = localizePathname(locale, '/workspace') const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) const [email, setEmail] = useState('') @@ -64,9 +74,9 @@ 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(defaultCallbackUrl) const registrationHref = getAuthRegistrationHref(registrationMode) - const registrationLabel = getAuthRegistrationLabel(registrationMode) + const registrationLabel = getAuthRegistrationLabel(copy, registrationMode) useEffect(() => { if (searchParams) { @@ -89,22 +99,24 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist 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 +129,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 +142,30 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist } try { - const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' + const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : defaultCallbackUrl await client.signIn.sso({ email: emailValue, callbackURL: safeCallbackUrl, - errorCallbackURL: `/sso?error=sso_failed&callbackUrl=${encodeURIComponent(safeCallbackUrl)}`, + errorCallbackURL: `${localizePathname(locale, '/sso')}?error=sso_failed&callbackUrl=${encodeURIComponent(safeCallbackUrl)}`, }) } catch (err) { logger.error('SSO sign-in failed', { error: err, email: emailValue }) - 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.' + errorMessage = ssoCopy.errors.providerNotConfigured } else if (err.message.includes('INVALID_EMAIL_DOMAIN')) { - errorMessage = 'Email domain not configured for SSO. Please contact your administrator.' + errorMessage = ssoCopy.errors.invalidEmailDomain } else if (err.message.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' + errorMessage = ssoCopy.errors.network } else if (err.message.includes('rate limit')) { - errorMessage = 'Too many requests. Please wait a moment before trying again.' + errorMessage = ssoCopy.errors.rateLimit } else if (err.message.includes('SSO_DISABLED')) { - errorMessage = 'SSO authentication is disabled. Please use another sign-in method.' + errorMessage = ssoCopy.errors.ssoDisabled } else { - errorMessage = err.message + errorMessage = ssoCopy.errors.failed } } @@ -163,9 +178,9 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist return ( <> {registrationMode === 'waitlist' ? : null} @@ -174,12 +189,12 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist
- +
@@ -214,7 +229,7 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist
- Or + {ssoCopy.divider}
@@ -227,14 +242,14 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist className='w-full rounded-md shadow-sm hover:bg-gray-50' type='button' > - Sign in with email + {commonCopy.signInWithEmail}
{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}
diff --git a/apps/tradinggoose/app/(auth)/verify/use-verification.test.ts b/apps/tradinggoose/app/(auth)/verify/use-verification.test.ts new file mode 100644 index 000000000..720ea2b91 --- /dev/null +++ b/apps/tradinggoose/app/(auth)/verify/use-verification.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' +import { getVerificationErrorMessage } from './use-verification' + +const { mockUseRouter, mockUseSearchParams, mockUseSession } = vi.hoisted(() => { + const mockUseRouter = { + push: vi.fn(), + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + } + + return { + mockUseRouter, + mockUseSearchParams: vi.fn(() => new URLSearchParams()), + mockUseSession: vi.fn(() => ({ refetch: vi.fn() })), + } +}) + +vi.mock('next-intl', () => ({ + useLocale: () => 'es', +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => mockUseRouter, + useSearchParams: mockUseSearchParams, +})) + +vi.mock('@/lib/auth-client', () => ({ + client: { + emailOtp: { + sendVerificationOtp: vi.fn(), + }, + signIn: { + emailOtp: vi.fn(), + }, + }, + useSession: mockUseSession, +})) + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +describe('getVerificationErrorMessage', () => { + const copy = getPublicCopy('es').auth.verify + + it('returns the localized invalid verification message', () => { + expect(getVerificationErrorMessage(copy, { message: 'invalid verification code' })).toBe( + copy.errors.invalid + ) + }) + + it('returns the localized expired verification message', () => { + expect(getVerificationErrorMessage(copy, new Error('verification expired'))).toBe( + copy.errors.expired + ) + }) + + it('exposes the localized resend failure message from the catalog', () => { + expect(copy.errors.resendFailed).toBe( + 'No se pudo reenviar el código de verificación. Vuelve a intentarlo más tarde.' + ) + }) +}) diff --git a/apps/tradinggoose/app/(auth)/verify/use-verification.ts b/apps/tradinggoose/app/(auth)/verify/use-verification.ts index af88eb428..252189bff 100644 --- a/apps/tradinggoose/app/(auth)/verify/use-verification.ts +++ b/apps/tradinggoose/app/(auth)/verify/use-verification.ts @@ -2,15 +2,37 @@ import { useEffect, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { client, useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' +import type { PublicCopy } from '@/i18n/public-copy' +import { type LocaleCode, localizeHref } from '@/i18n/utils' const logger = createLogger('useVerification') +type VerifyCopy = PublicCopy['auth']['verify'] + +export function getVerificationErrorMessage(copy: VerifyCopy, error: unknown) { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : error && typeof error === 'object' && 'message' in error + ? String((error as { message?: unknown }).message ?? '') + : '' + + if (message.includes('expired')) return copy.errors.expired + if (message.includes('invalid')) return copy.errors.invalid + if (message.includes('attempts')) return copy.errors.attempts + + return copy.errors.generic +} interface UseVerificationParams { hasEmailService: boolean isProduction: boolean isEmailVerificationEnabled: boolean + copy: VerifyCopy } interface UseVerificationReturn { @@ -33,8 +55,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('') @@ -120,12 +144,12 @@ export function useVerification({ if (isInviteFlow && redirectUrl) { window.location.href = 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 +158,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 +186,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) @@ -213,7 +227,7 @@ export function useVerification({ if (isInviteFlow && redirectUrl) { window.location.href = redirectUrl } else { - router.push('/workspace') + router.push(localizeHref(locale, '/workspace')) } } diff --git a/apps/tradinggoose/app/(auth)/verify/verify-content.test.tsx b/apps/tradinggoose/app/(auth)/verify/verify-content.test.tsx new file mode 100644 index 000000000..35b3b1d59 --- /dev/null +++ b/apps/tradinggoose/app/(auth)/verify/verify-content.test.tsx @@ -0,0 +1,160 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' +import { VerifyContent } from './verify-content' + +const { useLocaleMock, useRouterPushMock, useVerificationMock } = vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'es'), + useRouterPushMock: vi.fn(), + useVerificationMock: vi.fn(), +})) + +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ + push: useRouterPushMock, + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + }), +})) + +vi.mock('@/app/(auth)/verify/use-verification', () => ({ + useVerification: useVerificationMock, +})) + +vi.mock('@/app/fonts/inter', () => ({ + inter: { className: '' }, +})) + +vi.mock('@/app/fonts/soehne/soehne', () => ({ + soehne: { className: '' }, +})) + +vi.mock('@/components/ui/input-otp', () => ({ + InputOTP: ({ children }: { children?: React.ReactNode }) =>
{children}
, + InputOTPGroup: ({ children }: { children?: React.ReactNode }) =>
{children}
, + InputOTPSlot: () =>
, +})) + +describe('VerifyContent', () => { + let container: HTMLDivElement + let root: Root + + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + beforeEach(() => { + vi.clearAllMocks() + 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 verification copy and resend/back actions', async () => { + const copy = getPublicCopy('es') + const resendCode = vi.fn() + + useVerificationMock.mockReturnValue({ + otp: '', + email: 'user@example.com', + isLoading: false, + isVerified: false, + isInvalidOtp: false, + errorMessage: '', + isOtpComplete: false, + hasEmailService: true, + isProduction: false, + isEmailVerificationEnabled: true, + verifyCode: vi.fn(), + resendCode, + handleOtpChange: vi.fn(), + }) + + await act(async () => { + root.render( + + ) + }) + + expect(container.querySelector('p.uppercase')?.textContent).toBe(copy.auth.verify.eyebrow) + expect(container.querySelector('h1')?.textContent).toBe(copy.auth.verify.pendingTitle) + expect(container.querySelector('div.space-y-6 p')?.textContent).toBe( + 'Introduce 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.' + ) + expect(container.textContent).toContain(copy.auth.verify.instructionsWithService) + expect(container.textContent).toContain(copy.auth.verify.verifyButton) + expect(container.textContent).toContain(copy.auth.verify.resendPrompt) + expect(container.textContent).toContain(copy.auth.common.backToSignup) + + const resendButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === copy.auth.verify.resendButton + ) + expect(resendButton).toBeTruthy() + + await act(async () => { + resendButton?.click() + }) + + expect(resendCode).toHaveBeenCalledTimes(1) + expect(container.textContent).toContain('Reenviar en 30s') + }) + + it('renders the translated verified header state', async () => { + const copy = getPublicCopy('es') + + useVerificationMock.mockReturnValue({ + otp: '', + email: 'user@example.com', + isLoading: false, + isVerified: true, + isInvalidOtp: false, + errorMessage: '', + isOtpComplete: false, + hasEmailService: true, + isProduction: false, + isEmailVerificationEnabled: true, + verifyCode: vi.fn(), + resendCode: vi.fn(), + handleOtpChange: vi.fn(), + }) + + await act(async () => { + root.render( + + ) + }) + + expect(container.querySelector('h1')?.textContent).toBe(copy.auth.verify.verifiedTitle) + expect(container.textContent).toContain(copy.auth.verify.verifiedDescription) + }) +}) diff --git a/apps/tradinggoose/app/(auth)/verify/verify-content.tsx b/apps/tradinggoose/app/(auth)/verify/verify-content.tsx index 219c4d186..34d7a3ae2 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, getPublicCopy } from '@/i18n/public-copy' +import { 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 = getPublicCopy(locale) + 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}

@@ -167,16 +182,18 @@ 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, + })} ) : ( )}

@@ -203,7 +220,7 @@ function VerificationForm({ }} 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.tsx b/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx index b809a329a..a19d7f3d4 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 { getPublicCopy } from '@/i18n/public-copy' +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 = getPublicCopy(locale) + 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 } @@ -42,15 +62,13 @@ export function WaitlistForm() { | null if (!response.ok) { - throw new Error(payload?.error || 'Failed to join the waitlist') + throw new Error(waitlistCopy.rejected) } setStatus(payload?.status ?? 'pending') setEmail(normalizedEmail) - } catch (submissionError) { - setError( - submissionError instanceof Error ? submissionError.message : 'Failed to join the waitlist' - ) + } catch { + setError(waitlistCopy.rejected) } finally { setIsSubmitting(false) } @@ -61,14 +79,14 @@ export function WaitlistForm() {
- + setEmail(event.target.value)} - placeholder='Enter your email' + placeholder={commonCopy.enterYourEmail} autoComplete='email' autoCapitalize='none' autoCorrect='off' @@ -79,13 +97,13 @@ export function WaitlistForm() { )} />

- Use the email address you want reviewed for platform access. + {waitlistCopy.helperText}

@@ -97,19 +115,16 @@ 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{' '} + {waitlistCopy.approvedPrefix}{' '} - sign up + {waitlistCopy.signUpLink} . @@ -119,9 +134,9 @@ export function WaitlistForm() { {status === 'signed_up' ? ( - This email already has access. Continue to{' '} + {waitlistCopy.signedUpPrefix}{' '} - login + {waitlistCopy.loginLink} . @@ -130,17 +145,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..23632d74e 100644 --- a/apps/tradinggoose/app/(landing)/blog/[slug]/page.tsx +++ b/apps/tradinggoose/app/(landing)/blog/[slug]/page.tsx @@ -1,20 +1,23 @@ +import { Clock } from 'lucide-react' +import type { Metadata } from 'next' import Image from 'next/image' import Link from 'next/link' import { notFound } from 'next/navigation' -import { Metadata } from 'next' -import { Clock } from 'lucide-react' +import { getLocale } from 'next-intl/server' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' 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 { getPostBySlug } from '../lib/posts' -import { formatBlogDate } from '../lib/heading-slugs' +import { getPublicCopy } from '@/i18n/public-copy' +import { getOpenGraphLocale, locales, localizePathname, localizeUrl } from '@/i18n/utils' +import AiSummarize from '../components/ai-summarize' import BreadcrumbNav from '../components/breadcrumb-nav' -import MarkdownTitle from '../components/markdown-title' import MarkdownContent from '../components/markdown-content' -import TableOfContents from '../components/table-of-contents' +import MarkdownTitle from '../components/markdown-title' import SocialShare from '../components/social-share' -import AiSummarize from '../components/ai-summarize' +import TableOfContents from '../components/table-of-contents' +import { formatBlogDate } from '../lib/heading-slugs' +import { getPostBySlug } from '../lib/posts' interface PostPageProps { params: Promise<{ slug: string }> @@ -24,13 +27,20 @@ export const dynamic = 'force-dynamic' /** Strip markdown link syntax for meta tags: [text](url) → text */ function toPlainTitle(md: string): string { - return md.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\n/g, ' ').trim() + return md + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/\n/g, ' ') + .trim() } export async function generateMetadata({ params }: PostPageProps): Promise { const { slug } = await params - const post = await getPostBySlug(slug) + const locale = (await getLocale()) as (typeof locales)[number] + const copy = getPublicCopy(locale) + const post = await getPostBySlug(slug, locale) if (!post) return {} + const canonicalPath = `/blog/${slug}` + const localizedCanonicalPath = localizePathname(locale, canonicalPath) // Plain-text title for , og:title, twitter:title — browsers & social // platforms don't render Markdown, so strip link syntax for clean display. @@ -38,16 +48,18 @@ export async function generateMetadata({ params }: PostPageProps): Promise<Metad const plainTitle = toPlainTitle(post.title) return { - title: `${plainTitle} | TradingGoose Blog`, + title: `${plainTitle} | ${copy.blog.pageTitle}`, description: post.description, alternates: { - canonical: `/blog/${slug}`, + canonical: localizedCanonicalPath, }, openGraph: { title: plainTitle, description: post.description, type: 'article', - url: `/blog/${slug}`, + url: localizeUrl('https://tradinggoose.ai', locale, canonicalPath), + locale: getOpenGraphLocale(locale), + alternateLocale: locales.filter((value) => value !== locale).map(getOpenGraphLocale), images: post.image ? [{ url: post.image, width: 1200, height: 630, alt: plainTitle }] : [], }, twitter: { @@ -61,11 +73,14 @@ export async function generateMetadata({ params }: PostPageProps): Promise<Metad export default async function PostPage({ params }: PostPageProps) { const { slug } = await params - const post = await getPostBySlug(slug) + const locale = (await getLocale()) as (typeof locales)[number] + const copy = getPublicCopy(locale) + const post = await getPostBySlug(slug, locale) if (!post) notFound() const { title, date, image, authors, tags, toc, content, readingTime } = post - const postPath = `/blog/${slug}` + const canonicalPath = `/blog/${slug}` + const localizedPath = localizePathname(locale, canonicalPath) const plainTitle = toPlainTitle(title) const wordCount = content.split(/\s+/).length @@ -84,68 +99,70 @@ export default async function PostPage({ params }: PostPageProps) { name: a.name, url: a.profileUrl, image: a.avatar, - sameAs: [ - `https://github.com/${a.github}`, - ...(a.x ? [`https://x.com/${a.x}`] : []), - ], + sameAs: [`https://github.com/${a.github}`, ...(a.x ? [`https://x.com/${a.x}`] : [])], })), }), publisher: { '@id': 'https://tradinggoose.ai/#organization' }, - mainEntityOfPage: { '@type': 'WebPage', '@id': `https://tradinggoose.ai/blog/${slug}` }, + mainEntityOfPage: { + '@type': 'WebPage', + '@id': localizeUrl('https://tradinggoose.ai', locale, canonicalPath), + }, ...(tags?.length && { keywords: tags.join(', '), articleSection: tags[0] }), - inLanguage: 'en-US', + inLanguage: getOpenGraphLocale(locale), } return ( - <BlogLayout path={`/blog/${slug}`} title={plainTitle}> + <BlogLayout path={canonicalPath} title={plainTitle}> <script - type="application/ld+json" - dangerouslySetInnerHTML={{ __html: JSON.stringify(blogPostingSchema).replace(/</g, '\\u003c') }} + type='application/ld+json' + dangerouslySetInnerHTML={{ + __html: JSON.stringify(blogPostingSchema).replace(/</g, '\\u003c'), + }} /> <article> <BreadcrumbNav pageTitle={title} /> <MarkdownTitle title={title} - as="h1" - className="mt-2 inline-block text-4xl font-bold leading-tight lg:text-5xl" + as='h1' + className='mt-2 inline-block font-bold text-4xl leading-tight lg:text-5xl' /> - <div className="mt-4 flex flex-wrap items-center justify-between gap-y-3 text-sm text-muted-foreground"> - <div className="flex items-center gap-3"> + <div className='mt-4 flex flex-wrap items-center justify-between gap-y-3 text-muted-foreground text-sm'> + <div className='flex items-center gap-3'> {authors?.length ? authors.map((author) => ( - <Link - key={author.github} - href={author.profileUrl} - className="flex items-center gap-2" - target="_blank" - rel="noopener noreferrer" - > - <Avatar className="h-6 w-6"> - <AvatarImage src={author.avatar} alt={author.name} /> - <AvatarFallback className="text-xs"> - {author.name.charAt(0)} - </AvatarFallback> - </Avatar> - <span className="font-medium text-foreground">{author.name}</span> - </Link> - )) + <Link + key={author.github} + href={author.profileUrl} + className='flex items-center gap-2' + target='_blank' + rel='noopener noreferrer' + > + <Avatar className='h-6 w-6'> + <AvatarImage src={author.avatar} alt={author.name} /> + <AvatarFallback className='text-xs'>{author.name.charAt(0)}</AvatarFallback> + </Avatar> + <span className='font-medium text-foreground'>{author.name}</span> + </Link> + )) : null} - <span className="text-muted-foreground/50">·</span> - {date && <time dateTime={date}>{formatBlogDate(date)}</time>} - <span className="text-muted-foreground/50">·</span> - <div className="flex items-center gap-1"> - <Clock className="size-3.5" /> - <span>{readingTime} min read</span> + <span className='text-muted-foreground/50'>·</span> + {date && <time dateTime={date}>{formatBlogDate(date, 'long', locale)}</time>} + <span className='text-muted-foreground/50'>·</span> + <div className='flex items-center gap-1'> + <Clock className='size-3.5' /> + <span> + {readingTime} {copy.blog.readTimeSuffix} + </span> </div> </div> {tags && tags.length > 0 && ( - <ul className="m-0 flex list-none gap-2 p-0"> + <ul className='m-0 flex list-none gap-2 p-0'> {tags.map((tag) => ( <li key={tag}> - <Badge variant="secondary">{tag}</Badge> + <Badge variant='secondary'>{tag}</Badge> </li> ))} </ul> @@ -158,23 +175,23 @@ export default async function PostPage({ params }: PostPageProps) { alt={title} width={1200} height={600} - className="my-8 h-auto w-full rounded-md border bg-muted transition-colors" + className='my-8 h-auto w-full rounded-md border bg-muted transition-colors' priority /> )} {/* Two-column: content + TOC */} - <div className="relative lg:gap-10 xl:grid xl:grid-cols-[1fr_250px]"> - <div className="w-full min-w-0"> + <div className='relative lg:gap-10 xl:grid xl:grid-cols-[1fr_250px]'> + <div className='w-full min-w-0'> <MarkdownContent content={content} /> </div> - <div className="hidden text-sm xl:block"> - <div className="sticky top-10 max-h-[calc(100vh-4rem)] pt-4"> - <SocialShare text={title} path={postPath} /> - <Separator className="my-4" /> - <AiSummarize path={postPath} title={title} /> - <Separator className="my-4" /> + <div className='hidden text-sm xl:block'> + <div className='sticky top-10 max-h-[calc(100vh-4rem)] pt-4'> + <SocialShare text={title} path={localizedPath} /> + <Separator className='my-4' /> + <AiSummarize path={localizedPath} title={title} /> + <Separator className='my-4' /> <TableOfContents toc={toc} /> </div> </div> diff --git a/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx b/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx index 40c260590..27020f02a 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx @@ -1,6 +1,6 @@ 'use client' -import Link from 'next/link' +import { useLocale } from 'next-intl' import { Home } from 'lucide-react' import { Breadcrumb, @@ -10,26 +10,32 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb' +import { Link } from '@/i18n/navigation' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' interface BreadcrumbNavProps { pageTitle: string } export default function BreadcrumbNav({ pageTitle }: Readonly<BreadcrumbNavProps>) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) + return ( <Breadcrumb className="mb-6"> <BreadcrumbList> <BreadcrumbItem> <BreadcrumbLink asChild> <Link href="/" className="flex items-center gap-2"> - <Home className="h-4 w-4" /> Home + <Home className="h-4 w-4" /> {copy.blog.home} </Link> </BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbLink asChild> - <Link href="/blog">Blog</Link> + <Link href="/blog">{copy.blog.breadcrumbBlog}</Link> </BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> diff --git a/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx b/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx index 0c64e5179..822cbd9e5 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx @@ -2,11 +2,14 @@ import { useState } from 'react' import { FileText, SearchIcon, SearchX } from 'lucide-react' +import { useLocale } from 'next-intl' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' import PostCard from './post-card' import type { Post } from '../lib/types' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' interface FilteredPostProps { posts: Post[] @@ -14,6 +17,8 @@ interface FilteredPostProps { export default function FilteredPosts({ posts }: FilteredPostProps) { const [searchValue, setSearchValue] = useState('') + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) if (posts.length === 0) { return ( @@ -22,8 +27,8 @@ export default function FilteredPosts({ posts }: FilteredPostProps) { <EmptyMedia variant="icon"> <FileText /> </EmptyMedia> - <EmptyTitle>No posts yet</EmptyTitle> - <EmptyDescription>Check back soon — new articles are on the way.</EmptyDescription> + <EmptyTitle>{copy.blog.emptyTitle}</EmptyTitle> + <EmptyDescription>{copy.blog.emptyDescription}</EmptyDescription> </EmptyHeader> </Empty> ) @@ -40,8 +45,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={copy.blog.searchPlaceholder} + aria-label={copy.blog.searchPlaceholder} className="w-full pl-12" id="search" /> @@ -62,8 +67,8 @@ export default function FilteredPosts({ posts }: FilteredPostProps) { <EmptyMedia variant="icon"> <SearchX /> </EmptyMedia> - <EmptyTitle>No posts matching “{searchValue}”</EmptyTitle> - <EmptyDescription>Try a different search term.</EmptyDescription> + <EmptyTitle>{copy.blog.noMatches.replace('{{query}}', searchValue)}</EmptyTitle> + <EmptyDescription>{copy.blog.noMatchesDescription}</EmptyDescription> </EmptyHeader> </Empty> )} diff --git a/apps/tradinggoose/app/(landing)/blog/components/post-card.tsx b/apps/tradinggoose/app/(landing)/blog/components/post-card.tsx index eb903dde4..b1e399c83 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/post-card.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/post-card.tsx @@ -1,13 +1,16 @@ 'use client' import Image from 'next/image' -import Link from 'next/link' import { Clock } from 'lucide-react' +import { useLocale } from 'next-intl' import { Card } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { formatBlogDate } from '../lib/heading-slugs' import MarkdownTitle from './markdown-title' import type { Post } from '../lib/types' +import { Link } from '@/i18n/navigation' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' interface PostCardProps { post: Post @@ -15,6 +18,9 @@ interface PostCardProps { } export default function PostCard({ post, index }: PostCardProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) + return ( <Card className="group relative flex flex-col space-y-2 rounded-2xl border p-3"> {post.image && ( @@ -43,12 +49,14 @@ export default function PostCard({ post, index }: PostCardProps) { )} <div className="mt-auto flex items-center justify-between gap-2 pt-4 text-sm text-muted-foreground"> - <span>{formatBlogDate(post.date, 'short')}</span> + <span>{formatBlogDate(post.date, 'short', locale)}</span> <div className="flex items-center gap-4"> <div className="flex items-center gap-1"> <Clock className="size-4" /> - <span>{post.readingTime} min read</span> + <span> + {post.readingTime} {copy.blog.readTimeSuffix} + </span> </div> {post.tags && post.tags.length > 0 && ( @@ -61,7 +69,7 @@ export default function PostCard({ post, index }: PostCardProps) { </div> <Link href={`/blog/${post.slug}`} className="absolute inset-0"> - <span className="sr-only">View Article</span> + <span className="sr-only">{copy.blog.viewArticle}</span> </Link> </Card> ) diff --git a/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.test.ts b/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.test.ts new file mode 100644 index 000000000..ae8efcd09 --- /dev/null +++ b/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { formatBlogDate } from './heading-slugs' + +describe('formatBlogDate', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('normalizes English locale codes and formats dates in UTC', () => { + const spy = vi.spyOn(Date.prototype, 'toLocaleDateString').mockReturnValue('February 14, 2024') + + expect(formatBlogDate('2024-02-14', 'long', 'en')).toBe('February 14, 2024') + expect(spy).toHaveBeenCalledWith('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }) + }) + + it('passes non-English locales through unchanged', () => { + const spy = vi.spyOn(Date.prototype, 'toLocaleDateString').mockReturnValue( + '14 de febrero de 2024' + ) + + expect(formatBlogDate('2024-02-14', 'long', 'es')).toBe('14 de febrero de 2024') + expect(spy).toHaveBeenCalledWith('es', { + month: 'long', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }) + }) +}) diff --git a/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts b/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts index 74a19c76a..1d40fad99 100644 --- a/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts +++ b/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts @@ -29,8 +29,14 @@ 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 = 'en-US' +): string { + const resolvedLocale = locale === 'en' ? 'en-US' : locale + + return new Date(dateStr).toLocaleDateString(resolvedLocale, { month: style === 'long' ? 'long' : 'short', day: 'numeric', year: 'numeric', diff --git a/apps/tradinggoose/app/(landing)/blog/lib/posts.test.ts b/apps/tradinggoose/app/(landing)/blog/lib/posts.test.ts new file mode 100644 index 000000000..c0e5c49ac --- /dev/null +++ b/apps/tradinggoose/app/(landing)/blog/lib/posts.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ + +import fs from 'fs' +import os from 'os' +import path from 'path' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' + +const mockResolveGitHubBlogSourceConfig = vi.fn() + +vi.mock('@/lib/system-services/runtime', () => ({ + resolveGitHubBlogSourceConfig: (...args: unknown[]) => + mockResolveGitHubBlogSourceConfig(...args), +})) + +describe('blog post loader', () => { + let rootDir = '' + let cwdSpy: ReturnType<typeof vi.spyOn> + + beforeEach(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tg-blog-')) + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(rootDir) + mockResolveGitHubBlogSourceConfig.mockResolvedValue({ + blogRepository: null, + blogBranch: 'main', + }) + + const contentDir = path.join(rootDir, 'app/(landing)/blog/content') + fs.mkdirSync(path.join(contentDir, 'en/post-one'), { recursive: true }) + fs.mkdirSync(path.join(contentDir, 'es/post-one'), { recursive: true }) + + fs.writeFileSync( + path.join(contentDir, 'en/post-one/index.mdx'), + `--- +title: English post +date: 2026-04-01 +description: English description +--- + +# English body +` + ) + fs.writeFileSync( + path.join(contentDir, 'es/post-one/index.mdx'), + `--- +title: Publicación en español +date: 2026-04-02 +description: Descripción en español +--- + +# Cuerpo español +` + ) + + vi.resetModules() + }) + + afterEach(() => { + cwdSpy.mockRestore() + fs.rmSync(rootDir, { recursive: true, force: true }) + vi.clearAllMocks() + }) + + it('prefers locale-specific content and falls back to English when translation is missing', async () => { + const { getAllPosts, getPostBySlug } = await import('./posts') + + const spanishPosts = await getAllPosts('es') + expect(spanishPosts).toHaveLength(1) + expect(spanishPosts[0]?.title).toBe('Publicación en español') + + const fallbackPosts = await getAllPosts('zh-CN') + expect(fallbackPosts).toHaveLength(1) + expect(fallbackPosts[0]?.title).toBe('English post') + + const fallbackPost = await getPostBySlug('post-one', 'zh-CN') + expect(fallbackPost?.title).toBe('English post') + }) +}) diff --git a/apps/tradinggoose/app/(landing)/blog/lib/posts.ts b/apps/tradinggoose/app/(landing)/blog/lib/posts.ts index 75d03d574..c842c915a 100644 --- a/apps/tradinggoose/app/(landing)/blog/lib/posts.ts +++ b/apps/tradinggoose/app/(landing)/blog/lib/posts.ts @@ -3,6 +3,7 @@ import fs from 'fs' import path from 'path' import matter from 'gray-matter' import { resolveGitHubBlogSourceConfig } from '@/lib/system-services/runtime' +import { defaultLocale, isLocaleCode, type LocaleCode } from '@/i18n/utils' import { normalizeHeadingText, textToSlug } from './heading-slugs' import type { Post, PostFrontmatter, ResolvedAuthor, TOC } from './types' @@ -37,6 +38,18 @@ type GitHubBlogSource = { branch: string } +type BlogPostCandidate = { + slug: string + locale?: LocaleCode + filePath: string + postDir: string +} + +type BlogPostIndex = { + source: GitHubBlogSource | null + candidatesBySlug: Map<string, BlogPostCandidate[]> +} + // --------------------------------------------------------------------------- // Shared utilities // --------------------------------------------------------------------------- @@ -84,6 +97,137 @@ function resolveAuthors(raw: PostFrontmatter['authors']): ResolvedAuthor[] { }) } +function groupCandidatesBySlug(candidates: BlogPostCandidate[]) { + const grouped = new Map<string, BlogPostCandidate[]>() + + for (const candidate of candidates) { + const existing = grouped.get(candidate.slug) ?? [] + existing.push(candidate) + grouped.set(candidate.slug, existing) + } + + return grouped +} + +function getLocaleSearchOrder(locale: LocaleCode) { + return locale === defaultLocale ? [defaultLocale, undefined] : [locale, defaultLocale, undefined] +} + +function resolveCandidate( + candidates: BlogPostCandidate[], + locale: LocaleCode +): BlogPostCandidate | undefined { + const priority = getLocaleSearchOrder(locale) + + for (const candidateLocale of priority) { + const match = candidates.find((candidate) => (candidate.locale ?? undefined) === candidateLocale) + if (match) return match + } + + return candidates[0] +} + +function extractCandidateFromPath(filePath: string): BlogPostCandidate | null { + const normalized = filePath.replace(/\\/g, '/').replace(/^\.\/+/, '') + const parts = normalized.split('/').filter(Boolean) + + if (parts.length === 0) { + return null + } + + const last = parts[parts.length - 1] + const parent = parts[parts.length - 2] + const localeRoot = parts[0] === 'content' ? parts[1] : parts[0] + + if (/^index\.mdx?$/.test(last)) { + if (!parent) { + return null + } + + const locale = isLocaleCode(localeRoot ?? '') ? (localeRoot as LocaleCode) : undefined + const slug = locale && parts[0] === 'content' ? parts[2] : locale ? parts[1] : parent + + if (!slug) { + return null + } + + return { + slug, + locale, + filePath, + postDir: parts.slice(0, -1).join('/'), + } + } + + if (/\.mdx?$/.test(last)) { + const slug = last.replace(/\.mdx?$/, '') + const locale = + isLocaleCode(localeRoot ?? '') && parts.length <= 2 ? (localeRoot as LocaleCode) : undefined + + return { + slug, + locale, + filePath, + postDir: parts.slice(0, -1).join('/'), + } + } + + return null +} + +function collectLocalCandidates( + directory: string, + inheritedLocale?: LocaleCode, + baseDir = directory +): BlogPostCandidate[] { + if (!fs.existsSync(directory)) { + return [] + } + + const entries = fs.readdirSync(directory, { withFileTypes: true }) + const candidates: BlogPostCandidate[] = [] + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name) + + if (entry.isDirectory()) { + if (!inheritedLocale && isLocaleCode(entry.name)) { + candidates.push(...collectLocalCandidates(entryPath, entry.name, baseDir)) + continue + } + + const indexFile = ['index.md', 'index.mdx'].find((filename) => + fs.existsSync(path.join(entryPath, filename)) + ) + + if (indexFile) { + candidates.push({ + slug: entry.name, + locale: inheritedLocale, + filePath: path.join(entryPath, indexFile), + postDir: path.relative(baseDir, entryPath) || entry.name, + }) + continue + } + + candidates.push(...collectLocalCandidates(entryPath, inheritedLocale, baseDir)) + continue + } + + if (entry.isFile() && /\.mdx?$/.test(entry.name)) { + const candidate = extractCandidateFromPath(entryPath) + if (candidate) { + candidates.push({ + ...candidate, + locale: inheritedLocale ?? candidate.locale, + }) + } + } + } + + return candidates +} + /** * Resolve image paths relative to the post's folder. * - Absolute URLs (https://...) are left as-is. @@ -158,7 +302,7 @@ function parsePost( } // --------------------------------------------------------------------------- -// GitHub source +// Blog index // --------------------------------------------------------------------------- interface GitHubTreeItem { @@ -166,7 +310,14 @@ interface GitHubTreeItem { type: string } -async function fetchPostsFromGitHub(source: GitHubBlogSource): Promise<Post[]> { +function createLocalBlogPostIndex(): BlogPostIndex { + return { + source: null, + candidatesBySlug: groupCandidatesBySlug(collectLocalCandidates(LOCAL_CONTENT_DIR)), + } +} + +async function createGitHubBlogPostIndex(source: GitHubBlogSource): Promise<BlogPostIndex> { const treeUrl = `https://api.github.com/repos/${source.repository}/git/trees/${source.branch}?recursive=1` const headers: Record<string, string> = { Accept: 'application/vnd.github.v3+json' } @@ -177,93 +328,78 @@ async function fetchPostsFromGitHub(source: GitHubBlogSource): Promise<Post[]> { if (!treeRes.ok) { console.error(`[blog] Failed to fetch GitHub tree: ${treeRes.status}`) - return [] + return { source, candidatesBySlug: new Map() } } const tree: { tree: GitHubTreeItem[] } = await treeRes.json() - // Find index.md/index.mdx inside top-level folders: my-post/index.md - const postFiles = tree.tree.filter( - (item) => item.type === 'blob' && /^[^/]+\/index\.mdx?$/.test(item.path) - ) - - const posts = await Promise.all( - postFiles.map(async (file) => { - const res = await fetch(rawGitHubUrl(source, file.path), FETCH_OPTIONS) - if (!res.ok) return null + const candidates = tree.tree + .filter((item) => item.type === 'blob' && /\.mdx?$/.test(item.path)) + .map((item) => extractCandidateFromPath(item.path)) + .filter((item): item is BlogPostCandidate => item !== null) - const text = await res.text() - // my-post/index.md → slug: "my-post", dir: "my-post" - const slug = file.path.split('/')[0] - return parsePost(slug, text, slug, source) - }) - ) - - return posts - .filter((p): p is Post => p !== null) - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) -} - -// --------------------------------------------------------------------------- -// Local filesystem source -// --------------------------------------------------------------------------- - -function fetchPostsFromLocal(): Post[] { - if (!fs.existsSync(LOCAL_CONTENT_DIR)) return [] - - const entries = fs.readdirSync(LOCAL_CONTENT_DIR, { withFileTypes: true }) - - const posts: (Post | null)[] = entries.map((entry) => { - if (entry.isDirectory()) { - // Folder-based: my-post/index.md - const indexFile = ['index.md', 'index.mdx'].find((f) => - fs.existsSync(path.join(LOCAL_CONTENT_DIR, entry.name, f)) - ) - if (!indexFile) return null - const filePath = path.join(LOCAL_CONTENT_DIR, entry.name, indexFile) - const content = fs.readFileSync(filePath, 'utf-8') - return parsePost(entry.name, content, entry.name, null) - } - if (entry.isFile() && /\.mdx?$/.test(entry.name)) { - // Flat file fallback: my-post.md - const filePath = path.join(LOCAL_CONTENT_DIR, entry.name) - const content = fs.readFileSync(filePath, 'utf-8') - const slug = entry.name.replace(/\.mdx?$/, '') - return parsePost(slug, content, slug, null) - } - return null - }) - - return posts - .filter((p): p is Post => p !== null) - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + return { + source, + candidatesBySlug: groupCandidatesBySlug(candidates), + } } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -// Deduplicate within a single server render pass (generateMetadata + page component) -export const getAllPosts = cache(async (): Promise<Post[]> => { +export const getBlogPostIndex = cache(async (): Promise<BlogPostIndex> => { try { const githubConfig = await resolveGitHubBlogSourceConfig() const repository = githubConfig.blogRepository if (!repository) { - return fetchPostsFromLocal() + return createLocalBlogPostIndex() } - return fetchPostsFromGitHub({ + return createGitHubBlogPostIndex({ repository, branch: githubConfig.blogBranch, }) } catch { console.warn('[blog] Failed to resolve GitHub blog settings, falling back to local content') - return fetchPostsFromLocal() + return createLocalBlogPostIndex() + } +}) + +export const getPostsFromIndex = cache( + async (locale: LocaleCode, index: BlogPostIndex): Promise<Post[]> => { + const posts = await Promise.all( + [...index.candidatesBySlug.entries()].map(async ([slug, slugCandidates]) => { + const candidate = resolveCandidate(slugCandidates, locale) + if (!candidate) return null + + if (index.source) { + const res = await fetch(rawGitHubUrl(index.source, candidate.filePath), FETCH_OPTIONS) + if (!res.ok) return null + + const text = await res.text() + return parsePost(slug, text, candidate.postDir, index.source) + } + + const content = fs.readFileSync(candidate.filePath, 'utf-8') + return parsePost(slug, content, candidate.postDir, null) + }) + ) + + return posts + .filter((p): p is Post => p !== null) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) } +) + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +// Deduplicate within a single server render pass (generateMetadata + page component) +export const getAllPosts = cache(async (locale: LocaleCode = defaultLocale): Promise<Post[]> => { + const index = await getBlogPostIndex() + return getPostsFromIndex(locale, index) }) -export async function getPostBySlug(slug: string): Promise<Post | null> { - const posts = await getAllPosts() +export const getPostBySlug = cache(async (slug: string, locale: LocaleCode = defaultLocale) => { + const posts = await getAllPosts(locale) return posts.find((post) => post.slug === slug) ?? null -} +}) diff --git a/apps/tradinggoose/app/(landing)/blog/page.tsx b/apps/tradinggoose/app/(landing)/blog/page.tsx index 55e85783c..3c46de3b8 100644 --- a/apps/tradinggoose/app/(landing)/blog/page.tsx +++ b/apps/tradinggoose/app/(landing)/blog/page.tsx @@ -1,25 +1,56 @@ -import { Metadata } from 'next' +import type { Metadata } from 'next' +import { getLocale } from 'next-intl/server' import BlogLayout from '@/app/(landing)/components/blog-layout' -import { getAllPosts } from './lib/posts' -import PageHeading from './components/page-heading' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { getOpenGraphLocale, locales, localizePathname, localizeUrl } from '@/i18n/utils' import FilteredPosts from './components/filtered-posts' +import PageHeading from './components/page-heading' +import { getAllPosts } from './lib/posts' + +export async function generateMetadata(): Promise<Metadata> { + const locale = (await getLocale()) as (typeof locales)[number] + const copy = getPublicCopy(locale) + const canonicalPath = '/blog' + const localizedCanonicalPath = localizePathname(locale, canonicalPath) + const canonicalUrl = localizeUrl('https://tradinggoose.ai', locale, canonicalPath) -export const metadata: Metadata = { - title: 'Blog | TradingGoose', - description: 'Articles about trading automation, workflow design, and building smarter strategies.', - alternates: { - canonical: '/blog', - }, + return { + title: copy.meta.blog.title, + description: copy.meta.blog.description, + alternates: { + canonical: localizedCanonicalPath, + languages: { + 'x-default': 'https://tradinggoose.ai/blog', + en: 'https://tradinggoose.ai/blog', + es: 'https://tradinggoose.ai/es/blog', + 'zh-CN': localizeUrl('https://tradinggoose.ai', 'zh-CN', '/blog'), + }, + }, + openGraph: { + title: copy.meta.blog.title, + description: copy.meta.blog.description, + url: canonicalUrl, + locale: getOpenGraphLocale(locale), + alternateLocale: locales.filter((value) => value !== locale).map(getOpenGraphLocale), + }, + twitter: { + card: 'summary', + title: copy.meta.blog.title, + description: copy.meta.blog.description, + }, + } } export default async function BlogPage() { - const posts = await getAllPosts() + const locale = (await getLocale()) as (typeof locales)[number] + const copy = getPublicCopy(locale) + const posts = await getAllPosts(locale) return ( - <BlogLayout path="/blog"> + <BlogLayout path='/blog'> <PageHeading - title="Blog" - description={`Insights on trading automation, workflow design, and building smarter strategies. ${posts.length} articles and counting.`} + title={copy.blog.pageTitle} + description={formatTemplate(copy.blog.pageDescription, { count: posts.length })} /> <FilteredPosts posts={posts} /> </BlogLayout> diff --git a/apps/tradinggoose/app/(landing)/components/blog-layout.test.tsx b/apps/tradinggoose/app/(landing)/components/blog-layout.test.tsx new file mode 100644 index 000000000..ada451e1f --- /dev/null +++ b/apps/tradinggoose/app/(landing)/components/blog-layout.test.tsx @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ + +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +const { mockGetLocale, mockGetPublicCopy } = vi.hoisted(() => ({ + mockGetLocale: vi.fn(), + mockGetPublicCopy: vi.fn(), +})) + +vi.mock('next-intl/server', () => ({ + getLocale: mockGetLocale, +})) + +vi.mock('@/i18n/public-copy', () => ({ + getPublicCopy: mockGetPublicCopy, +})) + +vi.mock('@/app/(landing)/components/footer/footer', () => ({ + default: () => <footer data-testid='footer' />, +})) + +vi.mock('@/app/(landing)/components/nav/public-nav', () => ({ + default: () => <nav data-testid='nav' />, +})) + +vi.mock('@/app/fonts/soehne/soehne', () => ({ + soehne: { className: 'soehne' }, +})) + +import BlogLayout from './blog-layout' + +describe('BlogLayout', () => { + it.each([ + { + locale: 'es' as const, + path: '/es/blog/trading-signals', + expectedItem: 'https://tradinggoose.ai/es/blog/trading-signals', + }, + { + locale: 'zh-CN' as const, + path: '/zh/blog/trading-signals', + expectedItem: 'https://tradinggoose.ai/zh/blog/trading-signals', + }, + ])( + 'normalizes localized breadcrumb paths for $locale', + async ({ locale, path, expectedItem }) => { + const publicLocale = locale === 'zh-CN' ? 'zh' : locale + + mockGetLocale.mockResolvedValue(locale) + mockGetPublicCopy.mockReturnValue({ + blog: { + home: 'Home', + breadcrumbBlog: 'Blog', + }, + }) + + const element = await BlogLayout({ + children: <div>Body</div>, + path, + title: 'Trading Signals', + }) + const markup = renderToStaticMarkup(element) + const scriptMatch = markup.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/) + + expect(scriptMatch).not.toBeNull() + + const structuredData = JSON.parse(scriptMatch?.[1] ?? '{}') as { + itemListElement: Array<{ item?: string }> + } + + expect(structuredData.itemListElement[0]?.item).toBe( + `https://tradinggoose.ai/${publicLocale}` + ) + expect(structuredData.itemListElement[1]?.item).toBe( + `https://tradinggoose.ai/${publicLocale}/blog` + ) + expect(structuredData.itemListElement[2]?.item).toBe(expectedItem) + expect(markup).not.toContain(`/${locale}/${locale}/blog/trading-signals`) + } + ) +}) diff --git a/apps/tradinggoose/app/(landing)/components/blog-layout.tsx b/apps/tradinggoose/app/(landing)/components/blog-layout.tsx index 798db6d29..9bcc13905 100644 --- a/apps/tradinggoose/app/(landing)/components/blog-layout.tsx +++ b/apps/tradinggoose/app/(landing)/components/blog-layout.tsx @@ -1,6 +1,9 @@ +import { getLocale } from 'next-intl/server' import Footer from '@/app/(landing)/components/footer/footer' import PublicNav from '@/app/(landing)/components/nav/public-nav' import { soehne } from '@/app/fonts/soehne/soehne' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode, localizeUrl, stripLocaleFromPathname } from '@/i18n/utils' interface BlogLayoutProps { children: React.ReactNode @@ -12,51 +15,58 @@ interface BlogLayoutProps { title?: string } -export default function BlogLayout({ children, path, title }: BlogLayoutProps) { +export default async function BlogLayout({ children, path, title }: BlogLayoutProps) { + const locale = (await getLocale()) as LocaleCode + const copy = getPublicCopy(locale) + const canonicalPath = path ? stripLocaleFromPathname(path).pathname : null const breadcrumbStructuredData = path ? { - '@context': 'https://schema.org', - '@type': 'BreadcrumbList', - itemListElement: [ - { - '@type': 'ListItem', - position: 1, - name: 'Home', - item: 'https://tradinggoose.ai', - }, - { - '@type': 'ListItem', - position: 2, - name: 'Blog', - item: 'https://tradinggoose.ai/blog', - }, - ...(title - ? [ - { - '@type': 'ListItem', - position: 3, - name: title, - item: `https://tradinggoose.ai${path}`, - }, - ] - : []), - ], - } + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: copy.blog.home, + item: localizeUrl('https://tradinggoose.ai', locale, '/'), + }, + { + '@type': 'ListItem', + position: 2, + name: copy.blog.breadcrumbBlog, + item: localizeUrl('https://tradinggoose.ai', locale, '/blog'), + }, + ...(title + ? [ + { + '@type': 'ListItem', + position: 3, + name: title, + item: localizeUrl('https://tradinggoose.ai', locale, canonicalPath ?? path), + }, + ] + : []), + ], + } : null return ( <main className={`${soehne.className} min-h-screen`}> {breadcrumbStructuredData && ( <script - type="application/ld+json" - dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbStructuredData).replace(/</g, '\\u003c') }} + type='application/ld+json' + dangerouslySetInnerHTML={{ + __html: JSON.stringify(breadcrumbStructuredData).replace(/</g, '\\u003c'), + }} /> )} <PublicNav /> - <div className="border-b border-border px-4 pt-10 pb-80 sm:px-12 md:px-20 lg:px-60">{children}</div> + <div className='border-border border-b px-4 pt-10 pb-80 sm:px-12 md:px-20 lg:px-60'> + {children} + </div> - <div className="relative z-20"> + <div className='relative z-20'> <Footer fullWidth /> </div> </main> diff --git a/apps/tradinggoose/app/(landing)/components/cta/cta.tsx b/apps/tradinggoose/app/(landing)/components/cta/cta.tsx index be5f58427..f65259f01 100644 --- a/apps/tradinggoose/app/(landing)/components/cta/cta.tsx +++ b/apps/tradinggoose/app/(landing)/components/cta/cta.tsx @@ -5,11 +5,16 @@ import { DiscordIcon } from '@/components/icons/icons' import { BackgroundRippleEffect } from '@/components/ui/background-ripple-effect' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { useLocale } from 'next-intl' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' export default function CallToAction() { const [email, setEmail] = useState('') const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') const [message, setMessage] = useState('') + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) const handleSubscribe = async (e: React.FormEvent) => { e.preventDefault() @@ -25,16 +30,16 @@ export default function CallToAction() { if (res.ok) { setStatus('success') - setMessage('Subscribed! Check your inbox.') + setMessage(copy.landing.cta.success) setEmail('') } else { const data = await res.json() setStatus('error') - setMessage(data.error || 'Something went wrong.') + setMessage(data.error || copy.landing.cta.error) } } catch { setStatus('error') - setMessage('Something went wrong.') + setMessage(copy.landing.cta.error) } } @@ -53,24 +58,24 @@ export default function CallToAction() { }} > <BackgroundRippleEffect cellSize={60} rows={12} cols={20} maskClassName='' interactive /> - </div> - <div className='relative z-10 flex flex-col gap-y-6 px-4'> - <div className='space-y-2'> - <h2 className='text-center font-semibold text-lg tracking-tight md:text-2xl'> - Let AI agents work your trading strategy. - </h2> - <p className='text-balance text-center text-muted-foreground text-sm md:text-base'> - See what the commutity is building with TradingGoose. - </p> - </div> - <div className='flex items-center justify-center'> - <Button variant='outline' className='bg-background' asChild> - <a href='https://discord.gg/wavf5JWhuT' target='_blank' rel='noopener noreferrer'> - <DiscordIcon className='size-4' /> - Join Discord - </a> - </Button> </div> + <div className='relative z-10 flex flex-col gap-y-6 px-4'> + <div className='space-y-2'> + <h2 className='text-center font-semibold text-lg tracking-tight md:text-2xl'> + {copy.landing.cta.title} + </h2> + <p className='text-balance text-center text-muted-foreground text-sm md:text-base'> + {copy.landing.cta.description} + </p> + </div> + <div className='flex items-center justify-center'> + <Button variant='outline' className='bg-background' asChild> + <a href='https://discord.gg/wavf5JWhuT' target='_blank' rel='noopener noreferrer'> + <DiscordIcon className='size-4' /> + {copy.landing.cta.joinDiscord} + </a> + </Button> + </div> {status === 'success' ? ( <p className='text-center text-emerald-500 text-sm'>{message}</p> ) : ( @@ -78,7 +83,7 @@ export default function CallToAction() { <Input type='email' required - placeholder='you@example.com' + placeholder={copy.landing.cta.placeholder} value={email} suppressHydrationWarning onChange={(e) => { @@ -93,7 +98,7 @@ export default function CallToAction() { disabled={status === 'loading'} className='h-9 shrink-0' > - {status === 'loading' ? 'Subscribing...' : 'Get updates'} + {status === 'loading' ? copy.landing.cta.subscribing : copy.landing.cta.subscribe} </Button> </form> )} 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 afb46be23..7174ed552 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 @@ -1,8 +1,11 @@ 'use client' 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 { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { createDefaultLayoutState, createLayoutNodeId, @@ -37,6 +40,8 @@ function LayoutPreviewPanelSurface({ onPanelSplitHorizontal?: () => void onPanelClose?: () => void }) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) const headerScrollRef = useRef<HTMLDivElement>(null) const bodyRef = useRef<HTMLDivElement>(null) const [panelSize, setPanelSize] = useState({ width: 0, height: 0 }) @@ -76,7 +81,7 @@ function LayoutPreviewPanelSurface({ ref={headerScrollRef} onWheel={handleHorizontalWheel} className='flex w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' - aria-label='Widget header' + aria-label={copy.landing.preview.layout.headerAriaLabel} > <div className='flex w-full flex-nowrap items-center gap-4 py-0.5 font-medium text-accent-foreground text-sm'> <div className='flex h-8 flex-grow basis-0 items-center justify-start gap-1 whitespace-nowrap pl-1 text-left' /> @@ -97,7 +102,7 @@ function LayoutPreviewPanelSurface({ > <div className='space-y-1'> <p className='font-semibold text-[11px] text-muted-foreground uppercase tracking-[0.24em]'> - Widget Size + {copy.landing.preview.layout.sizeLabel} </p> <p className='font-medium text-2xl text-foreground tabular-nums'> {formatPanelDimension(panelSize.width)} × {formatPanelDimension(panelSize.height)} 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 a46429c59..2a434c46b 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 @@ -2,6 +2,7 @@ import { type KeyboardEvent, useMemo, useState } from 'react' import { Activity, Check, ChevronDown, Search } from 'lucide-react' +import { useLocale } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -18,9 +19,9 @@ import { widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, } from '@/widgets/widgets/components/widget-header-control' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import type { LandingMarketIndicatorOption } from './indicators/catalog' - -const DEFAULT_PLACEHOLDER = 'Select indicators' const DROPDOWN_MAX_HEIGHT = '20rem' const DROPDOWN_VIEWPORT_HEIGHT = '14rem' @@ -28,7 +29,6 @@ type LandingIndicatorDropdownProps = { value: string[] options: LandingMarketIndicatorOption[] onChange: (ids: string[]) => void - placeholder?: string align?: 'start' | 'end' } @@ -36,9 +36,11 @@ export function LandingIndicatorDropdown({ value, options, onChange, - placeholder = DEFAULT_PLACEHOLDER, align = 'end', }: LandingIndicatorDropdownProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) + const indicatorDropdownCopy = copy.landing.preview.indicatorDropdown const [searchQuery, setSearchQuery] = useState('') const selectedIndicatorSet = new Set(value) @@ -55,12 +57,12 @@ export function LandingIndicatorDropdown({ }, [options, value]) const selectionLabel = useMemo(() => { - if (value.length === 0) return placeholder + if (value.length === 0) return indicatorDropdownCopy.placeholder const first = options.find((option) => option.id === value[0]) - if (!first) return placeholder + if (!first) return indicatorDropdownCopy.placeholder if (value.length === 1) return first.name return `${first.name} +${value.length - 1}` - }, [options, placeholder, value]) + }, [indicatorDropdownCopy.placeholder, options, value]) const colorBadge = ( <div @@ -116,7 +118,7 @@ export function LandingIndicatorDropdown({ </DropdownMenuTrigger> </span> </TooltipTrigger> - <TooltipContent side='top'>Select indicators</TooltipContent> + <TooltipContent side='top'>{indicatorDropdownCopy.tooltip}</TooltipContent> </Tooltip> <DropdownMenuContent align={align} @@ -135,7 +137,7 @@ export function LandingIndicatorDropdown({ <Input value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} - placeholder='Search indicators...' + placeholder={indicatorDropdownCopy.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' @@ -159,7 +161,9 @@ export function LandingIndicatorDropdown({ > {filteredOptions.length === 0 ? ( <p className='px-2 py-4 text-center text-muted-foreground text-xs'> - {searchQuery.trim() ? 'No indicators found.' : 'No indicators available yet.'} + {searchQuery.trim() + ? indicatorDropdownCopy.emptyWithQuery + : indicatorDropdownCopy.emptyWithoutQuery} </p> ) : ( <div className='flex w-full min-w-0 flex-col gap-1'> 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 00134730b..c9561b71f 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 @@ -2,7 +2,10 @@ import type { ReactNode, WheelEvent } from 'react' import { useCallback } from 'react' +import { useLocale } from 'next-intl' import { Card } from '@/components/ui/card' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import { getWidgetDefinition } from '@/widgets/registry' import { widgetHeaderControlClassName } from '@/widgets/widgets/components/widget-header-control' @@ -24,6 +27,8 @@ export function LandingWidgetShell({ children, className, }: LandingWidgetShellProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) const widgetDefinition = getWidgetDefinition(widgetKey) ?? getWidgetDefinition('empty') const WidgetIcon = widgetDefinition?.icon const handleWheel = useCallback((event: WheelEvent<HTMLDivElement>) => { @@ -44,14 +49,14 @@ export function LandingWidgetShell({ <div onWheel={handleWheel} className='flex w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' - aria-label='Widget header' + aria-label={copy.landing.preview.shell.headerAriaLabel} > <div className='flex w-full flex-nowrap items-center gap-4 py-0.5 font-medium text-accent-foreground text-sm'> <div className='flex h-8 flex-grow basis-0 items-center justify-start gap-1 whitespace-nowrap pl-1 text-left'> <button type='button' className={widgetHeaderControlClassName('font-semibold')} - aria-label={widgetDefinition?.title ?? 'Widget'} + aria-label={widgetDefinition?.title ?? copy.landing.preview.shell.widgetLabel} > <span className='flex items-center gap-1 text-muted-foreground hover:text-foreground'> {WidgetIcon ? <WidgetIcon className='h-4 w-4' aria-hidden='true' /> : null} diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx index e1cc559d4..5888d6997 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx @@ -1,9 +1,9 @@ 'use client' import React from 'react' -import { Indicator, PineTS } from 'pinets' +import { executeBrowserPineIndicator } from '@/lib/indicators/browser-execution' +import { useLocale } from 'next-intl' import { buildInputsMapFromMeta } from '@/lib/indicators/input-meta' -import { normalizeContext } from '@/lib/indicators/normalize-context' import { buildIndexMaps, mapMarketSeriesToBarsMs } from '@/lib/indicators/series-data' import type { BarMs, NormalizedPineOutput } from '@/lib/indicators/types' import type { ListingOption } from '@/lib/listing/identity' @@ -36,6 +36,8 @@ import type { } from '@/widgets/widgets/data_chart/types' import { DEFAULT_MANUAL_DRAW_TOOLS } from '@/widgets/widgets/data_chart/utils/draw-tools' import { buildIndicatorRefs } from '@/widgets/widgets/data_chart/utils/indicator-refs' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { DEFAULT_LANDING_MARKET_INDICATOR_IDS, LANDING_MARKET_INDICATOR_MAP, @@ -55,6 +57,9 @@ const MARKET_LISTING_LABEL = 'TradingGoose Data Chart' const MARKET_INTERVAL_LABEL = '1m' const LANDING_MARKET_PANEL_ID = 'landing-market-preview' const LANDING_MARKET_CHART_RESET_KEY = 'landing-market-preview' +// Keep the landing preview seed stable so server and client render the same +// initial bars before the post-mount live sync takes over. +const LANDING_MARKET_PREVIEW_ANCHOR_MS = Date.UTC(2025, 0, 1, 12, 0, 0) const LANDING_MARKET_WIDGET: NonNullable<WidgetInstance> = { key: 'data_chart', } @@ -70,32 +75,6 @@ const LANDING_MARKET_LISTING: ListingOption = { const BACKFILL_CHUNK_BARS = 1000 const BACKFILL_WINDOW_SEGMENTS = 3 -let landingPreviewTriggerShimLock: Promise<void> = Promise.resolve() - -const acquireLandingPreviewTriggerShim = async () => { - const previousLock = landingPreviewTriggerShimLock - let releaseLock: () => void = () => {} - landingPreviewTriggerShimLock = new Promise<void>((resolve) => { - releaseLock = resolve - }) - await previousLock - - const previousTrigger = (globalThis as { trigger?: (() => void) | undefined }).trigger - ;(globalThis as { trigger?: () => void }).trigger = () => { - // The landing preview does not collect trigger payloads; it only needs the - // global symbol to exist while client-side PineTS evaluates trigger(). - } - - return () => { - if (previousTrigger === undefined) { - ;(globalThis as { trigger?: () => void }).trigger = undefined - } else { - ;(globalThis as { trigger?: () => void }).trigger = previousTrigger - } - releaseLock() - } -} - type IndicatorExecutionState = { status: 'loading' | 'ready' | 'error' output: NormalizedPineOutput | null @@ -225,7 +204,10 @@ function MarketHeaderChartControls({ } export function MarketPreview() { - const initialBucketOpenTime = React.useMemo(() => getLiveBucketOpenTime(Date.now()), []) + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) + const indicatorUnavailableError = copy.landing.preview.market.indicatorUnavailableError + const initialBucketOpenTime = LANDING_MARKET_PREVIEW_ANCHOR_MS const mockBars = React.useMemo( () => buildSeedMarketBars(initialBucketOpenTime), [initialBucketOpenTime] @@ -465,59 +447,25 @@ export function MarketPreview() { status: 'error', output: null, warnings: [], - error: 'Indicator is not available in this showcase.', + error: indicatorUnavailableError, } satisfies IndicatorExecutionState, ] as const } try { - const pine = new PineTS(bars, 'SIM:GOOSE', '1m') - await pine.ready() const inputsMap = buildInputsMapFromMeta( indicator.definition.inputMeta, ref.inputs ?? undefined ) - - let context: any - const requiresTriggerShim = indicator.definition.pineCode.includes('trigger(') - let releaseTriggerShim: (() => void) | null = null - if (requiresTriggerShim) { - releaseTriggerShim = await acquireLandingPreviewTriggerShim() - } - try { - context = await pine.run(new Indicator(indicator.definition.pineCode, inputsMap)) - } finally { - releaseTriggerShim?.() - } - - const { output, warnings } = normalizeContext({ - context, - ...buildIndexMaps(bars), - triggerSignals: [], + const { output, warnings } = await executeBrowserPineIndicator({ + barsMs: bars, + pineCode: indicator.definition.pineCode, + inputsMap, + inputMeta: indicator.definition.inputMeta, + symbol: 'SIM:GOOSE', + interval: '1m', }) - // Post-process: apply input.bool visibility toggles that client-side - // PineTS can't handle in ternaries (TimeSeries objects are always truthy). - // Check each bool input — if its title matches "Show X line" and the value - // is false, null out the matching plot's data points so the line disappears - // but the series (and its pane control) remains. - if (output && indicator.definition.inputMeta) { - const meta = indicator.definition.inputMeta - Object.entries(meta).forEach(([title, inputDef]) => { - if (inputDef.type !== 'bool') return - const inputValue = inputsMap[title] - if (inputValue !== false && inputValue !== 0) return - const match = title.match(/^show\s+(.+?)(?:\s+line)?$/i) - if (!match) return - const plotName = match[1].toLowerCase() - output.series.forEach((s) => { - if (s.plot.title.toLowerCase().includes(plotName)) { - s.points = s.points.map((p) => ({ ...p, value: null })) - } - }) - }) - } - return [ ref.id, { @@ -550,7 +498,7 @@ export function MarketPreview() { return () => { isActive = false } - }, [bars, selectedIndicatorRefs]) + }, [bars, indicatorUnavailableError, selectedIndicatorRefs]) const dataVersion = bars[bars.length - 1]?.openTime ?? 0 const dataContext = React.useMemo<DataChartDataContext>( diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-canvas.test.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-canvas.test.tsx index bb745c8e8..104b25712 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-canvas.test.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview-canvas.test.tsx @@ -9,6 +9,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mockAdaptPreviewPayloadToCanvas = vi.fn() let lastReactFlowProps: Record<string, any> | null = null +vi.mock('next-intl', () => ({ + useLocale: () => 'en', +})) + vi.mock( '@/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-payload-adapter', () => ({ 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 40af6f30e..15869e70e 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 @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react' import { Minus, Plus } from 'lucide-react' +import { useLocale } from 'next-intl' import { Background, ConnectionLineType, @@ -14,6 +15,8 @@ import { } from '@xyflow/react' import '@xyflow/react/dist/style.css' import { Button } from '@/components/ui/button' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { WorkflowEdge } from '@/widgets/widgets/editor_workflow/components/workflow-edge/workflow-edge' @@ -39,6 +42,8 @@ const PREVIEW_CANVAS_EXTENT: [[number, number], [number, number]] = [ const PREVIEW_FIT_PADDING = 0.12 function WorkflowPreviewControls() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) const { zoomIn, zoomOut } = useReactFlow() const zoom = useStore((state: { transform?: number[]; viewport?: { zoom?: number } }) => Array.isArray(state.transform) ? state.transform[2] : state.viewport?.zoom @@ -54,7 +59,7 @@ function WorkflowPreviewControls() { onClick={() => zoomOut({ duration: 200 })} disabled={currentZoom <= 10} className='h-7 w-7 rounded-sm hover:bg-background disabled:cursor-not-allowed disabled:opacity-50' - aria-label='Zoom out workflow preview' + aria-label={copy.landing.preview.workflow.zoomOut} > <Minus className='h-3 w-3' /> </Button> @@ -67,7 +72,7 @@ function WorkflowPreviewControls() { onClick={() => zoomIn({ duration: 200 })} disabled={currentZoom >= 130} className='h-7 w-7 rounded-sm hover:bg-background disabled:cursor-not-allowed disabled:opacity-50' - aria-label='Zoom in workflow preview' + aria-label={copy.landing.preview.workflow.zoomIn} > <Plus className='h-3 w-3' /> </Button> diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview.tsx index ac80e11a0..c7bf5fb0b 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Check, ChevronDown, Workflow } from 'lucide-react' +import { useLocale } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -14,15 +15,21 @@ import { widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, } from '@/widgets/widgets/components/widget-header-control' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { LandingWidgetShell } from '../market-preview/landing-widget-shell' import { WorkflowPreviewCanvas } from './workflow-preview-canvas' import { TRADING_AGENT_WORKFLOW_DEMOS, type WorkflowPreviewDemo } from './workflow-preview-demos' function WorkflowSelector({ selectedDemo, + demos, + ariaLabel, onSelect, }: { selectedDemo: WorkflowPreviewDemo + demos: WorkflowPreviewDemo[] + ariaLabel: string onSelect: (demo: WorkflowPreviewDemo) => void }) { return ( @@ -33,7 +40,7 @@ function WorkflowSelector({ className={widgetHeaderControlClassName( 'group flex min-w-[240px] items-center justify-between gap-1' )} - aria-label={selectedDemo.name} + aria-label={ariaLabel} aria-haspopup='listbox' > <div @@ -62,7 +69,7 @@ function WorkflowSelector({ sideOffset={6} className={`${widgetHeaderMenuContentClassName} w-[260px]`} > - {TRADING_AGENT_WORKFLOW_DEMOS.map((demo) => { + {demos.map((demo) => { const isSelected = demo.id === selectedDemo.id return ( @@ -95,7 +102,19 @@ function WorkflowSelector({ } export function WorkflowPreview() { - const [selectedDemo, setSelectedDemo] = useState(TRADING_AGENT_WORKFLOW_DEMOS[0]) + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) + const workflowNames = [ + copy.landing.preview.workflow.demos.signalBriefing, + copy.landing.preview.workflow.demos.investmentDebate, + copy.landing.preview.workflow.demos.riskRouting, + ] + const workflowDemos = TRADING_AGENT_WORKFLOW_DEMOS.map((demo, index) => ({ + ...demo, + name: workflowNames[index] ?? demo.name, + })) + const [selectedDemoId, setSelectedDemoId] = useState(workflowDemos[0].id) + const selectedDemo = workflowDemos.find((demo) => demo.id === selectedDemoId) ?? workflowDemos[0] return ( <div className='flex h-full min-h-[560px] flex-col gap-4'> @@ -105,7 +124,9 @@ export function WorkflowPreview() { headerCenter={ <WorkflowSelector selectedDemo={selectedDemo} - onSelect={(demo) => setSelectedDemo(demo)} + demos={workflowDemos} + ariaLabel={copy.landing.preview.workflow.selectorAriaLabel} + onSelect={(demo) => setSelectedDemoId(demo.id)} /> } > diff --git a/apps/tradinggoose/app/(landing)/components/feature/feature.tsx b/apps/tradinggoose/app/(landing)/components/feature/feature.tsx index f3f9dee12..ccf6dfd4c 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/feature.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/feature.tsx @@ -2,9 +2,12 @@ import type React from 'react' import { ChartCandlestick, LayoutDashboardIcon, Workflow } from 'lucide-react' +import { useLocale } from 'next-intl' import { BackgroundRippleEffect } from '@/components/ui/background-ripple-effect' import { Card } from '@/components/ui/card' import { MotionPreset } from '@/components/ui/motion-preset' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import { useCardGlow } from '@/app/(landing)/components/use-card-glow' import { LayoutPreview } from './components/layout-preview/layout-preview' @@ -25,50 +28,23 @@ type FeatureRow = { icon: React.ReactNode } -const FEATURE_ROWS: FeatureRow[] = [ +const FEATURE_ROW_LAYOUT = [ { - badge: 'Workspace', - title: 'Widget layouts', - description: - 'Split the workspace to place widgets side by side or stacked. Save and switch between named layouts per workspace.', - bullets: [ - { title: 'Recursive splitting' }, - { title: 'Saved layouts per workspace' }, - { title: 'Shared widget action menu' }, - ], preview: <LayoutPreview />, - previewSide: 'left', + previewSide: 'left' as const, icon: <LayoutDashboardIcon className='size-5' />, }, { - badge: 'Charting', - title: 'Indicators and live data', - description: - 'Built-in indicators and a PineTS editor for writing custom ones. Connect your own data provider and monitor prices in real time.', - bullets: [ - { title: 'Configurable indicator inputs' }, - { title: 'Live re-execution per bar' }, - { title: 'Crosshair legend and chart markers' }, - ], preview: <MarketPreview />, - previewSide: 'right', + previewSide: 'right' as const, icon: <ChartCandlestick className='size-5' />, }, { - badge: 'Workflows', - title: 'AI-powered workflows', - description: - 'Build workflows on a canvas with AI agent blocks that make LLM-driven decisions. Integrate with Slack, Discord, GitHub, Gmail, and more — then route orders to Alpaca or Tradier.', - bullets: [ - { title: 'AI agent blocks for autonomous analysis and decisions' }, - { title: 'Integrations with Slack, Discord, GitHub, Gmail, and more' }, - { title: 'Data, condition, loop, parallel, and trading action blocks' }, - ], preview: <WorkflowPreview />, - previewSide: 'left', + previewSide: 'left' as const, icon: <Workflow className='size-5' />, }, -] +] as const function FeaturePoint({ title }: FeatureBullet) { return ( @@ -180,13 +156,24 @@ function FeatureRowSection({ } export default function Feature() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) useCardGlow() + const featureRows = copy.landing.features.rows.map((row, index) => ({ + badge: row.badge, + title: row.title, + description: row.description, + bullets: row.bullets.map((title) => ({ title })), + preview: FEATURE_ROW_LAYOUT[index]?.preview ?? null, + previewSide: FEATURE_ROW_LAYOUT[index]?.previewSide ?? 'left', + icon: FEATURE_ROW_LAYOUT[index]?.icon ?? <Workflow className='size-5' />, + })) return ( <section id='feature' className='relative isolate w-full overflow-hidden py-20 sm:py-28' - aria-label='Feature' + aria-label={copy.landing.features.eyebrow} > <div className='pointer-events-none absolute inset-0 z-[-1]' @@ -216,7 +203,7 @@ export default function Feature() { component='p' className='font-medium text-[11px] text-muted-foreground uppercase tracking-[0.24em]' > - Features + {copy.landing.features.eyebrow} </MotionPreset> <MotionPreset fade @@ -225,7 +212,7 @@ export default function Feature() { delay={0.12} className='mt-5 font-semibold text-3xl text-foreground tracking-tight sm:text-5xl' > - Your workspace, your way + {copy.landing.features.title} </MotionPreset> <MotionPreset fade @@ -234,12 +221,12 @@ export default function Feature() { delay={0.24} className='mx-auto mt-4 max-w-2xl text-lg text-muted-foreground leading-8' > - Layouts, charts, and workflows — each designed to work on its own or together. + {copy.landing.features.description} </MotionPreset> </div> <div className='mt-24 space-y-24 lg:mt-32 lg:space-y-56'> - {FEATURE_ROWS.map((row, index) => ( + {featureRows.map((row, index) => ( <FeatureRowSection key={row.title} {...row} index={index} /> ))} </div> diff --git a/apps/tradinggoose/app/(landing)/components/footer/footer.tsx b/apps/tradinggoose/app/(landing)/components/footer/footer.tsx index 17b31ca9a..22a0422db 100644 --- a/apps/tradinggoose/app/(landing)/components/footer/footer.tsx +++ b/apps/tradinggoose/app/(landing)/components/footer/footer.tsx @@ -1,40 +1,60 @@ import Image from 'next/image' -import Link from 'next/link' +import { getLocale } from 'next-intl/server' import { DiscordIcon, GithubIcon } from '@/components/icons/icons' 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 { getPublicCopy } from '@/i18n/public-copy' +import { localizeDocsUrl, type LocaleCode } from '@/i18n/utils' + +type FooterLinkKey = + | 'docs' + | 'blog' + | 'widgets' + | 'indicators' + | 'blocks' + | 'tools' + | 'changelog' + | 'privacy' + | 'licenses' + | 'terms' type FooterLink = { - label: string + key: FooterLinkKey href: string external: boolean } -const productLinks: FooterLink[] = [ - { label: 'Docs', href: 'https://docs.tradinggoose.ai', external: true }, - { label: 'Blog', href: '/blog', external: false }, - { label: 'Widgets', href: 'https://docs.tradinggoose.ai/widgets', external: true }, - { label: 'Indicators', href: 'https://docs.tradinggoose.ai/indicators', external: true }, - { label: 'Blocks', href: 'https://docs.tradinggoose.ai/blocks', external: true }, - { label: 'Tools', href: 'https://docs.tradinggoose.ai/tools', external: true }, - //{ label: 'Pricing', href: '/#pricing', external: false }, - { label: 'Changelog', href: '/changelog', external: false }, - //{ label: 'Enterprise', href: '', external: true }, -] +function getProductLinks(locale: LocaleCode): FooterLink[] { + return [ + { key: 'docs', href: localizeDocsUrl(locale), external: true }, + { key: 'blog', href: '/blog', external: false }, + { key: 'widgets', href: localizeDocsUrl(locale, '/widgets'), external: true }, + { key: 'indicators', href: localizeDocsUrl(locale, '/indicators'), external: true }, + { key: 'blocks', href: localizeDocsUrl(locale, '/blocks'), external: true }, + { key: 'tools', href: localizeDocsUrl(locale, '/tools'), external: true }, + //{ label: 'Pricing', href: '/#pricing', external: false }, + { key: 'changelog', href: '/changelog', external: false }, + //{ label: 'Enterprise', href: '', external: true }, + ] +} const legalLinks: FooterLink[] = [ - { label: 'Privacy Policy', href: '/privacy', external: false }, - { label: 'Licenses', href: '/licenses', external: false }, - { label: 'Terms of Service', href: '/terms', external: false }, + { key: 'privacy', href: '/privacy', external: false }, + { key: 'licenses', href: '/licenses', external: false }, + { key: 'terms', href: '/terms', external: false }, ] interface FooterProps { fullWidth?: boolean } -export default function Footer({ fullWidth = false }: FooterProps) { +export default async function Footer({ fullWidth = false }: FooterProps) { const brand = getBrandConfig() + const locale = (await getLocale()) as LocaleCode + const copy = getPublicCopy(locale) + const productLinks = getProductLinks(locale) const maxWidthClass = fullWidth ? 'max-w-[90vw]' : 'max-w-7xl' return ( @@ -63,7 +83,7 @@ export default function Footer({ fullWidth = false }: FooterProps) { </Link> <p className='max-w-[28rem] text-balance text-sm leading-relaxed'> - AI workflow platform for technical LLM trading + {copy.landing.footer.description} </p> <div className='flex items-center gap-4 max-sm:justify-center'> @@ -71,7 +91,7 @@ export default function Footer({ fullWidth = false }: FooterProps) { href='https://discord.gg/wavf5JWhuT' target='_blank' rel='noopener noreferrer' - aria-label='Discord' + aria-label={copy.landing.footer.social.discord} className='transition-colors duration-300 hover:text-foreground' > <DiscordIcon className='h-5 w-5' aria-hidden='true' /> @@ -80,7 +100,7 @@ export default function Footer({ fullWidth = false }: FooterProps) { href='https://github.com/TradingGoose/TradingGoose-Studio' target='_blank' rel='noopener noreferrer' - aria-label='GitHub' + aria-label={copy.landing.footer.social.github} className='transition-colors duration-300 hover:text-foreground' > <GithubIcon className='h-5 w-5' aria-hidden='true' /> @@ -88,7 +108,9 @@ export default function Footer({ fullWidth = false }: FooterProps) { </div> <p className='max-w-[28rem] text-balance font-light text-xs leading-relaxed'> - {`© ${new Date().getFullYear()} ${brand.name}. Built for visual trading workflows.`} + {copy.landing.footer.copyright + .replace('{{year}}', String(new Date().getFullYear())) + .replace('{{brand}}', brand.name)} </p> </div> @@ -97,22 +119,22 @@ export default function Footer({ fullWidth = false }: FooterProps) { {productLinks.map((link) => link.external ? ( <a - key={link.label} + key={link.key} href={link.href} target='_blank' rel='noopener noreferrer' className='transition-colors duration-300 hover:text-foreground' > - {link.label} + {copy.landing.footer.links[link.key]} </a> ) : ( <Link - key={link.label} + key={link.key} href={link.href} className='transition-colors duration-300 hover:text-foreground' prefetch={false} > - {link.label} + {copy.landing.footer.links[link.key]} </Link> ) )} @@ -121,12 +143,12 @@ export default function Footer({ fullWidth = false }: FooterProps) { <div className='flex flex-wrap gap-x-4 gap-y-2 py-3 text-xs max-sm:justify-center'> {legalLinks.map((link) => ( <Link - key={link.label} + key={link.key} href={link.href} className='transition-colors duration-300 hover:text-foreground' prefetch={false} > - {link.label} + {copy.landing.footer.links[link.key]} </Link> ))} </div> @@ -137,7 +159,7 @@ export default function Footer({ fullWidth = false }: FooterProps) { aria-hidden='true' className='-translate-x-1/2 -translate-y-8 -pt-8 sm:-pt-16 absolute left-1/2 z-0 hidden w-full max-w-70 overflow-hidden sm:block' > - <FooterHoverText text='HONK!' /> + <FooterHoverText text={copy.landing.footer.hoverText} /> <div className='pointer-events-none absolute inset-x-0 bottom-0 h-1/3' style={{ diff --git a/apps/tradinggoose/app/(landing)/components/hero/hero.tsx b/apps/tradinggoose/app/(landing)/components/hero/hero.tsx index b117c2304..0df8ca9c7 100644 --- a/apps/tradinggoose/app/(landing)/components/hero/hero.tsx +++ b/apps/tradinggoose/app/(landing)/components/hero/hero.tsx @@ -12,27 +12,18 @@ import { Workflow, } from 'lucide-react' import Image from 'next/image' -import Link from 'next/link' +import { useLocale } from 'next-intl' +import { Link } from '@/i18n/navigation' import { AnimatedBeam } from '@/components/ui/animated-beam' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { WordRotate } from '@/components/ui/word-rotate' import { getRegistrationPrimaryHref, - getRegistrationPrimaryLabel, type RegistrationMode, } from '@/lib/registration/shared' - -function getHeroBadgeText(registrationMode: RegistrationMode) { - switch (registrationMode) { - case 'disabled': - return 'Honk! TradingGoose-Studio coming soon' - case 'waitlist': - return 'Honk! Introducing TradingGoose-Studio' - case 'open': - return 'Honk! TradingGoose-Studio is here!' - } -} +import { getPrimaryRegistrationLabel, getPublicCopy } from '@/i18n/public-copy' +import { localizeDocsUrl, type LocaleCode } from '@/i18n/utils' const Hero = ({ registrationMode }: { registrationMode: RegistrationMode }) => { const containerRef = useRef<HTMLDivElement>(null) @@ -51,49 +42,53 @@ const Hero = ({ registrationMode }: { registrationMode: RegistrationMode }) => { const spanRef6 = useRef<HTMLSpanElement>(null) const spanRef7 = useRef<HTMLSpanElement>(null) const spanRef8 = useRef<HTMLSpanElement>(null) + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) const registrationPrimaryHref = getRegistrationPrimaryHref(registrationMode) - const registrationPrimaryLabel = getRegistrationPrimaryLabel(registrationMode) + const registrationPrimaryLabel = getPrimaryRegistrationLabel(copy, registrationMode) + const titleConnector = copy.landing.hero.titleConnector + const titleConnectorText = titleConnector ? `${titleConnector} ` : '' return ( <section className='flex-1 pt-8 sm:pt-16 lg:pt-24'> <div className='relative z-10 mx-auto flex max-w-7xl flex-col items-center gap-8 px-4 sm:gap-16 sm:px-6 lg:gap-24 lg:px-8'> <div className='flex flex-col items-center gap-4 text-center'> <Badge variant='outline' className='relative z-10 bg-background font-normal text-sm'> - {getHeroBadgeText(registrationMode)} + {copy.landing.hero.statusBadges[registrationMode]} </Badge> <h1 className='relative z-10 font-semibold text-2xl sm:text-3xl lg:font-bold lg:text-5xl'> - <WordRotate words={['Build', 'Test', 'Run']} duration={4000} /> your{' '} + <WordRotate words={copy.landing.hero.leadWords} duration={4000} />{' '} + {titleConnectorText} <WordRotate - words={['Trading Analysis', 'Signal Detection', 'Risk Assessment']} + words={copy.landing.hero.highlightWords} className='underline underline-offset-3' duration={7000} />{' '} - with TradingGoose + {copy.landing.hero.suffix} </h1> <p className='relative z-10 max-w-3xl text-lg text-muted-foreground leading-relaxed'> - 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. + {copy.landing.hero.description} </p> <div className='relative z-10 flex flex-wrap items-center justify-center gap-2'> <Badge variant='secondary' className='gap-1.5 px-3 py-1 font-normal text-xs'> <BotMessageSquareIcon className='size-3.5' /> - AI Agent Workflows + {copy.landing.hero.featureBadges[0]} </Badge> <Badge variant='secondary' className='gap-1.5 px-3 py-1 font-normal text-xs'> <ChartCandlestick className='size-3.5' /> - Custom Indicators + {copy.landing.hero.featureBadges[1]} </Badge> <Badge variant='secondary' className='gap-1.5 px-3 py-1 font-normal text-xs'> <ActivityIcon className='size-3.5' /> - Bring Your Own Data + {copy.landing.hero.featureBadges[2]} </Badge> <Badge variant='secondary' className='gap-1.5 px-3 py-1 font-normal text-xs'> <BlocksIcon className='size-3.5' /> - Integrations + {copy.landing.hero.featureBadges[3]} </Badge> </div> @@ -115,9 +110,9 @@ const Hero = ({ registrationMode }: { registrationMode: RegistrationMode }) => { className='bg-background font-semibold text-lg' asChild > - <Link href='https://docs.tradinggoose.ai' target='_blank' rel='noopener noreferrer'> - Learn More - </Link> + <a href={localizeDocsUrl(locale)} target='_blank' rel='noopener noreferrer'> + {copy.landing.hero.learnMore} + </a> </Button> </div> </div> diff --git a/apps/tradinggoose/app/(landing)/components/how-it-works/how-it-works.tsx b/apps/tradinggoose/app/(landing)/components/how-it-works/how-it-works.tsx index 4a4722129..55e29b102 100644 --- a/apps/tradinggoose/app/(landing)/components/how-it-works/how-it-works.tsx +++ b/apps/tradinggoose/app/(landing)/components/how-it-works/how-it-works.tsx @@ -1,43 +1,28 @@ -import { ArrowRightIcon, DatabaseIcon, ChartCandlestick, BotMessageSquareIcon, Workflow } from 'lucide-react' +import { BotMessageSquareIcon, ChartCandlestick, DatabaseIcon, Workflow } from 'lucide-react' +import { getLocale } from 'next-intl/server' import ProcessFlow from '@/app/(landing)/components/how-it-works/process-flow' import type { Process } from '@/app/(landing)/components/how-it-works/process-flow' - -import { Button } from '@/components/ui/button' import { MotionPreset } from '@/components/ui/motion-preset' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' + +const PROCESS_ICONS = [DatabaseIcon, ChartCandlestick, BotMessageSquareIcon, Workflow] + +export default async function HowItWorks() { + const locale = (await getLocale()) as LocaleCode + const copy = getPublicCopy(locale) + const processes: Process[] = copy.landing.howItWorks.processes.map((process, index) => { + const Icon = PROCESS_ICONS[index] ?? Workflow -const processes: Process[] = [ - { - id: '1', - icon: <DatabaseIcon />, - title: 'Connect your data', - description: - 'Plug in any market data provider and stream live prices into the workspace.', - }, - { - id: '2', - icon: <ChartCandlestick />, - title: 'Monitor with indicators', - description: - 'Write custom PineTS indicators that watch for the conditions you care about.', - }, - { - id: '3', - icon: <BotMessageSquareIcon />, - title: 'Analyze with AI agents', - description: - 'Let LLM-powered agent blocks evaluate signals, assess risk, and make decisions autonomously.', - }, - { - id: '4', - icon: <Workflow />, - title: 'Trigger workflows', - description: - 'When a signal fires, kick off a workflow to trade, alert, log, or anything else you define.', - }, -] + return { + id: String(index + 1), + icon: <Icon />, + title: process.title, + description: process.description, + } + }) -export default function HowItWorks() { return ( <section className='py-8 mt-24 sm:mt-32 sm:py-16 lg:mt-60 lg:py-24'> <div className='mx-auto px-4 sm:px-6 lg:px-24'> @@ -52,7 +37,7 @@ export default function HowItWorks() { component='p' className='font-medium text-[11px] text-muted-foreground uppercase tracking-[0.24em]' > - How it works + {copy.landing.howItWorks.eyebrow} </MotionPreset> <MotionPreset component='h2' @@ -63,7 +48,7 @@ export default function HowItWorks() { delay={0.15} transition={{ duration: 0.5 }} > - From data to decision + {copy.landing.howItWorks.title} </MotionPreset> <MotionPreset component='p' @@ -74,8 +59,7 @@ export default function HowItWorks() { delay={0.3} transition={{ duration: 0.5 }} > - Connect your own data sources, monitor markets with custom indicators, - let AI agents analyze what matters, and trigger workflows that act on your behalf. + {copy.landing.howItWorks.description} </MotionPreset> </div> diff --git a/apps/tradinggoose/app/(landing)/components/integrations/integrations.tsx b/apps/tradinggoose/app/(landing)/components/integrations/integrations.tsx index 51537925f..fa3fbb20a 100644 --- a/apps/tradinggoose/app/(landing)/components/integrations/integrations.tsx +++ b/apps/tradinggoose/app/(landing)/components/integrations/integrations.tsx @@ -6,7 +6,10 @@ 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 { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' type BrandLogo = { icon: React.ComponentType<{ className?: string }> @@ -103,27 +106,6 @@ const brandLogos: BrandLogo[] = [ { icon: Icons.DuckDuckGoIcon, name: 'DuckDuckGo' }, ] -// AI-readable entity list of integrations. This is the machine-readable -// companion to the visual logo marquee below — AI crawlers cannot parse -// React icon components, so we emit an ItemList JSON-LD snapshot. -const integrationsStructuredData = { - '@context': 'https://schema.org', - '@type': 'ItemList', - '@id': 'https://tradinggoose.ai/#integrations', - name: 'TradingGoose integrations', - description: - 'Third-party services, LLM providers, data sources, and tools that TradingGoose integrates with as callable workflow blocks.', - numberOfItems: brandLogos.length, - itemListElement: brandLogos.map((logo, index) => ({ - '@type': 'ListItem', - position: index + 1, - item: { - '@type': 'SoftwareApplication', - name: logo.name, - }, - })), -} - // Split logos evenly across 4 columns const perCol = Math.ceil(brandLogos.length / 4) const col1 = brandLogos.slice(0, perCol) @@ -142,7 +124,25 @@ function LogoAvatar({ icon: Icon, style }: BrandLogo) { } export default function Integrations() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) 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 ( <section id='integrations' className='py-8 sm:py-16 lg:py-24'> @@ -180,19 +180,15 @@ export default function Integrations() { /> <CardContent className='relative z-10 space-y-4 p-6'> <p className='font-medium text-[11px] text-muted-foreground uppercase tracking-[0.24em]'> - Integrations + {copy.landing.integrations.eyebrow} </p> <h2 className='font-semibold text-2xl md:text-3xl lg:text-4xl'> - LLM with more than just prompts. + {copy.landing.integrations.title} </h2> <div className='space-y-3 pt-10'> - {[ - 'Every integration becomes a tool your AI agents can call', - 'Built-in blocks for messaging, databases, cloud storage, CRMs, and search', - 'Custom MCP servers, skills, and tools you define yourself', - ].map((text) => ( + {copy.landing.integrations.bullets.map((text) => ( <div key={text} className='flex items-center gap-3'> <span className='h-px w-4 shrink-0 bg-primary' /> <p className='text-muted-foreground text-sm'>{text}</p> diff --git a/apps/tradinggoose/app/(landing)/components/legal-layout.tsx b/apps/tradinggoose/app/(landing)/components/legal-layout.tsx index 1d6c5c26d..5dbc36036 100644 --- a/apps/tradinggoose/app/(landing)/components/legal-layout.tsx +++ b/apps/tradinggoose/app/(landing)/components/legal-layout.tsx @@ -1,6 +1,9 @@ import Footer from '@/app/(landing)/components/footer/footer' import PublicNav from '@/app/(landing)/components/nav/public-nav' import { soehne } from '@/app/fonts/soehne/soehne' +import { getLocale } from 'next-intl/server' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode, localizeUrl } from '@/i18n/utils' interface LegalLayoutProps { title: string @@ -13,26 +16,28 @@ interface LegalLayoutProps { path?: string } -export default function LegalLayout({ title, children, path }: LegalLayoutProps) { +export default async function LegalLayout({ title, children, path }: LegalLayoutProps) { + const locale = (await getLocale()) as LocaleCode + const copy = getPublicCopy(locale) const breadcrumbStructuredData = path ? { - '@context': 'https://schema.org', - '@type': 'BreadcrumbList', - itemListElement: [ - { - '@type': 'ListItem', - position: 1, - name: 'Home', - item: 'https://tradinggoose.ai', - }, - { - '@type': 'ListItem', - position: 2, - name: title, - item: `https://tradinggoose.ai${path}`, - }, - ], - } + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: copy.nav.homeLabel, + item: localizeUrl('https://tradinggoose.ai', locale, '/'), + }, + { + '@type': 'ListItem', + position: 2, + name: title, + item: localizeUrl('https://tradinggoose.ai', locale, path), + }, + ], + } : null return ( diff --git a/apps/tradinggoose/app/(landing)/components/monitor-preview/fetch-listings.ts b/apps/tradinggoose/app/(landing)/components/monitor-preview/fetch-listings.ts index 5f2efce67..115fa8621 100644 --- a/apps/tradinggoose/app/(landing)/components/monitor-preview/fetch-listings.ts +++ b/apps/tradinggoose/app/(landing)/components/monitor-preview/fetch-listings.ts @@ -1,5 +1,4 @@ import { normalizeListingOptions } from '@/components/listing-selector/fetchers' -import { readServerJsonCache, writeServerJsonCache } from '@/lib/cache/server-json-cache' import type { ListingOption } from '@/lib/listing/identity' import { marketClient } from '@/lib/market/client/client' import { MARKET_API_VERSION } from '@/lib/market/client/constants' @@ -9,26 +8,9 @@ import { sortMonitorListings, } from '@/app/(landing)/components/monitor-preview/listing-preference' -// Stale-while-revalidate window: -// - Data stays cached for CACHE_TTL (24h) so we never serve absolutely cold. -// - A request is considered "fresh" if fetched within FRESH_WINDOW (15 min). -// - When stale-but-cached, we return immediately and kick off a background -// revalidation without blocking the SSR render. -const MONITOR_LISTINGS_CACHE_TTL_SECONDS = 24 * 60 * 60 -const MONITOR_LISTINGS_FRESH_WINDOW_MS = 15 * 60 * 1000 -// v6: switched to one request per PREFERRED_MARKET_CODES with per-market limit. -const MONITOR_LISTINGS_CACHE_KEY = 'landing-monitor-listings:v6' const MONITOR_PREVIEW_ROW_LIMIT = 20 const MONITOR_LISTINGS_TIMEOUT_MS = 4000 -type CachedEnvelope = { - data: ListingOption[] - fetchedAt: number -} - -// Deduplicate concurrent revalidations across simultaneous SSR requests. -let inflightRevalidation: Promise<ListingOption[]> | null = null - // Per-market result budget. Total pool = PER_MARKET_LIMIT * markets (5) = 50. const MONITOR_PER_MARKET_LIMIT = 10 @@ -44,28 +26,6 @@ function buildMarketQuery(marketCode: string): string { }).toString() } -const FALLBACK_STOCKS: ListingOption[] = [ - ['AAPL', 'Apple Inc.', 'stock'], - ['TSM', 'Taiwan Semiconductor', 'stock'], - ['ASML', 'ASML Holding', 'stock'], - ['SONY', 'Sony Group', 'stock'], - ['SAP', 'SAP SE', 'stock'], - ['NVO', 'Novo Nordisk', 'stock'], - ['BABA', 'Alibaba Group', 'stock'], - ['SHOP', 'Shopify', 'stock'], - ['MELI', 'MercadoLibre', 'stock'], - ['RELX', 'RELX', 'stock'], -].map(([base, name, assetClass]) => ({ - listing_id: `fallback-${base.toLowerCase()}`, - base_id: '', - quote_id: '', - listing_type: 'default' as const, - base, - name, - iconUrl: '', - assetClass, -})) - async function requestMarketListings(marketCode: string): Promise<ListingOption[]> { const response = await marketClient.makeRequest<{ data?: ListingOption[] | ListingOption | null @@ -84,61 +44,13 @@ async function requestMonitorListings(): Promise<ListingOption[]> { PREFERRED_MARKET_CODES.map((code) => requestMarketListings(code)) ) - const combined = results.flatMap((result) => - result.status === 'fulfilled' ? result.value : [] - ) + const combined = results.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) // filterToPreferredMarkets is belt-and-suspenders in case the upstream // ignored the market filter and returned listings from other exchanges. - return sortMonitorListings(filterToPreferredMarkets(combined)).slice( - 0, - MONITOR_PREVIEW_ROW_LIMIT - ) -} - -async function revalidateCache(): Promise<ListingOption[]> { - if (inflightRevalidation) return inflightRevalidation - - inflightRevalidation = (async () => { - try { - const listings = await requestMonitorListings() - if (listings.length > 0) { - const envelope: CachedEnvelope = { data: listings, fetchedAt: Date.now() } - await writeServerJsonCache( - MONITOR_LISTINGS_CACHE_KEY, - envelope, - MONITOR_LISTINGS_CACHE_TTL_SECONDS - ) - return listings - } - return [] - } catch { - return [] - } finally { - inflightRevalidation = null - } - })() - - return inflightRevalidation + return sortMonitorListings(filterToPreferredMarkets(combined)).slice(0, MONITOR_PREVIEW_ROW_LIMIT) } export async function fetchMonitorStocks(): Promise<ListingOption[]> { - const cached = await readServerJsonCache<CachedEnvelope>(MONITOR_LISTINGS_CACHE_KEY) - - if (cached && Array.isArray(cached.data) && cached.data.length > 0) { - const isStale = Date.now() - cached.fetchedAt > MONITOR_LISTINGS_FRESH_WINDOW_MS - if (isStale) { - // Stale-while-revalidate: serve the cached data immediately, refresh - // in the background without blocking this response. - void revalidateCache() - } - return cached.data.slice(0, MONITOR_PREVIEW_ROW_LIMIT) - } - - // No cache at all — first ever request or Redis miss. Don't let a slow - // upstream stall the SSR: kick off the revalidation in the background and - // serve FALLBACK_STOCKS immediately. The client-side refresh in - // monitor-preview.tsx will populate real data once the fetch lands. - void revalidateCache() - return FALLBACK_STOCKS + return requestMonitorListings().catch(() => []) } diff --git a/apps/tradinggoose/app/(landing)/components/monitor-preview/monitor-preview.tsx b/apps/tradinggoose/app/(landing)/components/monitor-preview/monitor-preview.tsx index d23695629..78cd1df48 100644 --- a/apps/tradinggoose/app/(landing)/components/monitor-preview/monitor-preview.tsx +++ b/apps/tradinggoose/app/(landing)/components/monitor-preview/monitor-preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { AnimatePresence, motion } from 'framer-motion' +import { useLocale } from 'next-intl' import { fetchListings } from '@/components/listing-selector/fetchers' import { MarketListingRow } from '@/components/listing-selector/listing/row' import { Badge } from '@/components/ui/badge' @@ -13,6 +14,8 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import type { ListingOption } from '@/lib/listing/identity' import { filterToPreferredMarkets, @@ -30,51 +33,6 @@ type MonitorEntry = { status: 'pending' | 'running' | 'success' | 'failed' } -const INDICATORS = [ - { name: 'RSI < 30', color: '#8b5cf6' }, - { name: 'MACD Cross', color: '#14b8a6' }, - { name: 'EMA 21/50', color: '#f59e0b' }, - { name: 'Supertrend', color: '#ef4444' }, - { name: 'BB Squeeze', color: '#3b82f6' }, - { name: 'Volume Spike', color: '#10b981' }, -] - -const WORKFLOWS = [ - { name: 'Sentiment Analysis', color: '#6366f1' }, - { name: 'Risk Assessment', color: '#f59e0b' }, - { name: 'Portfolio Rebalance', color: '#22c55e' }, - { name: 'Earnings Report Check', color: '#3b82f6' }, - { name: 'Social Media Scan', color: '#8b5cf6' }, - { name: 'Volatility Analysis', color: '#ef4444' }, - { name: 'Sector Correlation', color: '#14b8a6' }, -] - -const STATUS_CONFIG: Record< - MonitorEntry['status'], - { label: string; className: string; dotClassName: string } -> = { - pending: { - label: 'Pending', - className: 'bg-muted text-muted-foreground', - dotClassName: 'bg-muted-foreground/60', - }, - running: { - label: 'Running', - className: 'bg-blue-500/15 text-blue-500 border-blue-500/20', - dotClassName: 'bg-blue-500', - }, - success: { - label: 'Success', - className: 'bg-emerald-500/15 text-emerald-500 border-emerald-500/20', - dotClassName: 'bg-emerald-500', - }, - failed: { - label: 'Failed', - className: 'bg-destructive/15 text-destructive border-destructive/20', - dotClassName: 'bg-destructive', - }, -} - const INITIAL_STATUSES: MonitorEntry['status'][] = [ 'success', 'running', @@ -100,10 +58,15 @@ function buildMarketRefreshParams(marketCode: string): Record<string, string> { } } -function createRandomEntry(stocks: ListingOption[], counter: number): MonitorEntry { +function createRandomEntry( + stocks: ListingOption[], + counter: number, + indicators: Array<{ name: string; color: string }>, + workflows: Array<{ name: string; color: string }> +): MonitorEntry { const stock = stocks[Math.floor(Math.random() * stocks.length)] - const indicator = INDICATORS[Math.floor(Math.random() * INDICATORS.length)] - const workflow = WORKFLOWS[Math.floor(Math.random() * WORKFLOWS.length)] + const indicator = indicators[Math.floor(Math.random() * indicators.length)] + const workflow = workflows[Math.floor(Math.random() * workflows.length)] return { id: `entry-${counter}`, @@ -126,9 +89,13 @@ function advanceStatus(status: MonitorEntry['status']): MonitorEntry['status'] { return status } -function seedEntries(stocks: ListingOption[]): MonitorEntry[] { +function seedEntries( + stocks: ListingOption[], + indicators: Array<{ name: string; color: string }>, + workflows: Array<{ name: string; color: string }> +): MonitorEntry[] { return Array.from({ length: Math.min(INITIAL_ROWS, stocks.length) }, (_, index) => ({ - ...createRandomEntry(stocks, index), + ...createRandomEntry(stocks, index, indicators, workflows), status: INITIAL_STATUSES[index] as MonitorEntry['status'], })) } @@ -138,6 +105,11 @@ function isFallbackStock(stock: ListingOption): boolean { } export default function MonitorPreview({ stocks }: { stocks: ListingOption[] }) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) + const monitorCopy = copy.landing.monitorSection + const indicatorOptions = monitorCopy.indicatorOptions + const workflowOptions = monitorCopy.workflowOptions const [liveStocks, setLiveStocks] = useState(stocks) // Entries pick stock/indicator/workflow via Math.random(). Seeding in // useState would diverge between SSR and hydration, producing different @@ -150,8 +122,8 @@ export default function MonitorPreview({ stocks }: { stocks: ListingOption[] }) }, [stocks]) useEffect(() => { - setEntries(seedEntries(liveStocks)) - }, [liveStocks]) + setEntries(seedEntries(liveStocks, indicatorOptions, workflowOptions)) + }, [indicatorOptions, liveStocks, workflowOptions]) useEffect(() => { if (!stocks.some(isFallbackStock)) return @@ -186,7 +158,10 @@ export default function MonitorPreview({ stocks }: { stocks: ListingOption[] }) ...entry, status: advanceStatus(entry.status), })) - const nextEntries = [createRandomEntry(liveStocks, Date.now()), ...updated] + const nextEntries = [ + createRandomEntry(liveStocks, Date.now(), indicatorOptions, workflowOptions), + ...updated, + ] return nextEntries.slice(0, MAX_ROWS) }) @@ -195,7 +170,33 @@ export default function MonitorPreview({ stocks }: { stocks: ListingOption[] }) timeoutId = setTimeout(tick, 1500 + Math.random() * 5500) return () => clearTimeout(timeoutId) - }, [liveStocks]) + }, [indicatorOptions, liveStocks, workflowOptions]) + + const STATUS_CONFIG: Record< + MonitorEntry['status'], + { label: string; className: string; dotClassName: string } + > = { + pending: { + label: monitorCopy.statuses.pending, + className: 'bg-muted text-muted-foreground', + dotClassName: 'bg-muted-foreground/60', + }, + running: { + label: monitorCopy.statuses.running, + className: 'bg-blue-500/15 text-blue-500 border-blue-500/20', + dotClassName: 'bg-blue-500', + }, + success: { + label: monitorCopy.statuses.success, + className: 'bg-emerald-500/15 text-emerald-500 border-emerald-500/20', + dotClassName: 'bg-emerald-500', + }, + failed: { + label: monitorCopy.statuses.failed, + className: 'bg-destructive/15 text-destructive border-destructive/20', + dotClassName: 'bg-destructive', + }, + } return ( <div className='relative max-h-[420px] w-full overflow-hidden rounded-lg border bg-background/50 backdrop-blur-sm'> @@ -203,11 +204,17 @@ export default function MonitorPreview({ stocks }: { stocks: ListingOption[] }) <Table className='table-fixed'> <TableHeader> <TableRow className='hover:bg-transparent'> - <TableHead className='w-[14rem] max-sm:w-[3rem] max-sm:px-2'>Listing</TableHead> - <TableHead className='w-[10rem] max-sm:w-auto max-sm:px-2'>Indicator</TableHead> - <TableHead className='w-[12rem] max-sm:w-auto max-sm:px-2'>Workflow</TableHead> + <TableHead className='w-[14rem] max-sm:w-[3rem] max-sm:px-2'> + {monitorCopy.tableHeaders.listing} + </TableHead> + <TableHead className='w-[10rem] max-sm:w-auto max-sm:px-2'> + {monitorCopy.tableHeaders.indicator} + </TableHead> + <TableHead className='w-[12rem] max-sm:w-auto max-sm:px-2'> + {monitorCopy.tableHeaders.workflow} + </TableHead> <TableHead className='w-[6rem] text-right max-sm:w-[3rem] max-sm:px-2'> - Status + {monitorCopy.tableHeaders.status} </TableHead> </TableRow> </TableHeader> diff --git a/apps/tradinggoose/app/(landing)/components/monitor-preview/monitor-section.tsx b/apps/tradinggoose/app/(landing)/components/monitor-preview/monitor-section.tsx index a02b2b694..e160110c4 100644 --- a/apps/tradinggoose/app/(landing)/components/monitor-preview/monitor-section.tsx +++ b/apps/tradinggoose/app/(landing)/components/monitor-preview/monitor-section.tsx @@ -1,8 +1,14 @@ +import { getLocale } from 'next-intl/server' + import { MotionPreset } from '@/components/ui/motion-preset' import { fetchMonitorStocks } from '@/app/(landing)/components/monitor-preview/fetch-listings' import MonitorPreview from '@/app/(landing)/components/monitor-preview/monitor-preview' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' export default async function MonitorSection() { + const locale = (await getLocale()) as LocaleCode + const copy = getPublicCopy(locale) const stocks = await fetchMonitorStocks() return ( <section className='py-8 sm:py-16 lg:py-24'> @@ -28,7 +34,7 @@ export default async function MonitorSection() { component='p' className='font-medium text-[11px] text-muted-foreground uppercase tracking-[0.24em]' > - Live monitors + {copy.landing.monitorSection.eyebrow} </MotionPreset> <MotionPreset component='h2' @@ -39,7 +45,7 @@ export default async function MonitorSection() { delay={0.15} transition={{ duration: 0.5 }} > - Indicators that trigger workflows + {copy.landing.monitorSection.title} </MotionPreset> <MotionPreset component='p' @@ -50,17 +56,11 @@ export default async function MonitorSection() { delay={0.3} transition={{ duration: 0.5 }} > - Set up monitors that watch your indicators on live market data. When a signal fires, a - workflow runs automatically — place orders, send alerts, log results, or anything - else. + {copy.landing.monitorSection.description} </MotionPreset> <div className='space-y-2'> - {[ - 'Connect any streaming data provider with your own credentials', - 'Choose an indicator and interval to monitor per listing', - 'Route triggers to any deployed workflow', - ].map((text, i) => ( + {copy.landing.monitorSection.bullets.map((text, i) => ( <MotionPreset key={text} fade diff --git a/apps/tradinggoose/app/(landing)/components/nav/locale-switcher.test.ts b/apps/tradinggoose/app/(landing)/components/nav/locale-switcher.test.ts new file mode 100644 index 000000000..27c255165 --- /dev/null +++ b/apps/tradinggoose/app/(landing)/components/nav/locale-switcher.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { buildLocaleSwitchHref } from './locale-switcher' + +describe('buildLocaleSwitchHref', () => { + it('strips any existing locale prefix before rebuilding localized hrefs', () => { + expect( + buildLocaleSwitchHref( + 'en', + '/es/blog/trading-signals', + new URLSearchParams('from=nav&source=landing') + ) + ).toBe('/blog/trading-signals?from=nav&source=landing') + + expect( + buildLocaleSwitchHref( + 'zh-CN', + '/blog/trading-signals', + new URLSearchParams('from=nav&source=landing') + ) + ).toBe('/zh/blog/trading-signals?from=nav&source=landing') + + expect(buildLocaleSwitchHref('en', '/zh/blog/trading-signals', new URLSearchParams(''))).toBe( + '/blog/trading-signals' + ) + }) +}) diff --git a/apps/tradinggoose/app/(landing)/components/nav/locale-switcher.ts b/apps/tradinggoose/app/(landing)/components/nav/locale-switcher.ts new file mode 100644 index 000000000..6ab781b2d --- /dev/null +++ b/apps/tradinggoose/app/(landing)/components/nav/locale-switcher.ts @@ -0,0 +1,17 @@ +import { localizePathname, stripLocaleFromPathname, type LocaleCode } from '@/i18n/utils' + +export function buildLocaleSwitchHref( + locale: LocaleCode, + pathname: string | null | undefined, + searchParams: { toString(): string } | null | undefined +) { + const normalizedPathname = stripLocaleFromPathname(pathname || '/').pathname + const localizedPathname = localizePathname(locale, normalizedPathname) + const queryString = searchParams?.toString() + + return queryString ? `${localizedPathname}?${queryString}` : localizedPathname +} + +export function navigateToLocaleHref(href: string) { + window.location.assign(href) +} diff --git a/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx b/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx index 4dfe4ee0b..0329ead57 100644 --- a/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx +++ b/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx @@ -9,31 +9,125 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getRegistrationModeForRender } from '@/lib/registration/service' import Nav from './nav' import PublicNav from './public-nav' +import * as localeSwitcher from './locale-switcher' -const mockPush = vi.fn() +const { useLocaleMock, usePathnameMock, useSearchParamsMock } = vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'en'), + usePathnameMock: vi.fn(() => '/blog'), + useSearchParamsMock: vi.fn(() => new URLSearchParams('')), +})) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const React = await import('react') + + const DropdownMenuContext = React.createContext<{ + open?: boolean + onOpenChange?: (open: boolean) => void + }>({}) + + const DropdownMenu = ({ + children, + open, + onOpenChange, + }: { + children?: any + open?: boolean + onOpenChange?: (open: boolean) => void + }) => ( + <DropdownMenuContext.Provider value={{ open, onOpenChange }}> + {children} + </DropdownMenuContext.Provider> + ) + + const DropdownMenuTrigger = ({ + asChild, + children, + }: { + asChild?: boolean + children?: any + }) => { + const context = React.useContext(DropdownMenuContext) + + const handleClick = (event: any) => { + const child = children as React.ReactElement<any> | null + + if (React.isValidElement(child) && typeof (child.props as any).onClick === 'function') { + ;(child.props as any).onClick(event) + } + + context.onOpenChange?.(!context.open) + } + + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children as React.ReactElement<any>, { + onClick: handleClick, + } as any) + } + + return ( + <button type='button' onClick={handleClick}> + {children} + </button> + ) + } + + const DropdownMenuContent = ({ + children, + className, + ...props + }: any) => { + const context = React.useContext(DropdownMenuContext) + + if (!context.open) { + return null + } + + return ( + <div className={className} role='menu' {...props}> + {children} + </div> + ) + } + + const DropdownMenuGroup = ({ children }: { children?: any }) => <div>{children}</div> + + const DropdownMenuItem = ({ + children, + className, + onSelect, + ...props + }: any) => ( + <button type='button' role='menuitem' className={className} onClick={onSelect} {...props}> + {children} + </button> + ) + + const DropdownMenuSeparator = () => <hr aria-hidden='true' /> + + return { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + } +}) vi.mock('@/lib/registration/service', () => ({ getRegistrationModeForRender: vi.fn(), })) -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - push: mockPush, - }), +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, })) -vi.mock('next/image', () => ({ - default: ({ - alt, - priority: _priority, - ...props - }: React.ImgHTMLAttributes<HTMLImageElement> & { priority?: boolean }) => ( - <img alt={alt ?? ''} {...props} /> - ), +vi.mock('next/navigation', () => ({ + useSearchParams: useSearchParamsMock, })) -vi.mock('next/link', () => ({ - default: ({ +vi.mock('@/i18n/navigation', () => ({ + Link: ({ children, href, prefetch: _prefetch, @@ -47,6 +141,17 @@ vi.mock('next/link', () => ({ {children} </a> ), + usePathname: usePathnameMock, +})) + +vi.mock('next/image', () => ({ + default: ({ + alt, + priority: _priority, + ...props + }: React.ImgHTMLAttributes<HTMLImageElement> & { priority?: boolean }) => ( + <img alt={alt ?? ''} {...props} /> + ), })) vi.mock('@/app/fonts/soehne/soehne', () => ({ @@ -66,14 +171,19 @@ vi.mock('@/lib/branding/branding', () => ({ describe('landing nav registration mode', () => { let container: HTMLDivElement let root: Root + let navigateSpy: ReturnType<typeof vi.spyOn> const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } beforeEach(() => { + vi.clearAllMocks() reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true - mockPush.mockReset() + useLocaleMock.mockReturnValue('en') + usePathnameMock.mockReturnValue('/blog') + useSearchParamsMock.mockReturnValue(new URLSearchParams('')) + navigateSpy = vi.spyOn(localeSwitcher, 'navigateToLocaleHref').mockImplementation(() => {}) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -114,6 +224,38 @@ describe('landing nav registration mode', () => { expect(container.textContent).not.toContain('Login') }) + it('navigates to the locale-prefixed URL when the language changes', async () => { + vi.mocked(getRegistrationModeForRender).mockResolvedValue('waitlist') + usePathnameMock.mockReturnValue('/blog') + useSearchParamsMock.mockReturnValue(new URLSearchParams('from=nav&source=landing')) + + await act(async () => { + root.render(await PublicNav()) + }) + + const trigger = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('English') + ) + + expect(trigger).toBeTruthy() + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + const zhLocaleItem = Array.from(container.querySelectorAll('[role="menuitem"]')).find((item) => + item.textContent?.includes('简体中文') + ) + + expect(zhLocaleItem).toBeTruthy() + + await act(async () => { + zhLocaleItem?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + expect(navigateSpy).toHaveBeenCalledWith('/zh/blog?from=nav&source=landing') + }) + it('does not render auth controls when auth buttons are hidden', async () => { await act(async () => { root.render(<Nav variant='auth' hideAuthButtons />) diff --git a/apps/tradinggoose/app/(landing)/components/nav/nav.tsx b/apps/tradinggoose/app/(landing)/components/nav/nav.tsx index d1173e39a..fe3332165 100644 --- a/apps/tradinggoose/app/(landing)/components/nav/nav.tsx +++ b/apps/tradinggoose/app/(landing)/components/nav/nav.tsx @@ -1,10 +1,12 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' -import { MenuIcon } from 'lucide-react' +import { useEffect, useState } from 'react' +import { ChevronDownIcon, Check, LanguagesIcon, MenuIcon } from 'lucide-react' import Image from 'next/image' -import Link from 'next/link' -import { useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' +import { usePathname } from '@/i18n/navigation' +import { Link } from '@/i18n/navigation' +import { useSearchParams } from 'next/navigation' import { GithubIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { @@ -20,11 +22,16 @@ import { useBrandConfig } from '@/lib/branding/branding' import { createLogger } from '@/lib/logs/console/logger' import { getRegistrationPrimaryHref, - getRegistrationPrimaryLabel, type RegistrationMode, } from '@/lib/registration/shared' import { getFormattedGitHubStars } from '@/app/(landing)/actions/github' import { soehne } from '@/app/fonts/soehne/soehne' +import { getPrimaryRegistrationLabel, getPublicCopy } from '@/i18n/public-copy' +import { localizeDocsUrl, locales, type LocaleCode } from '@/i18n/utils' +import { + buildLocaleSwitchHref, + navigateToLocaleHref, +} from './locale-switcher' const logger = createLogger('nav') @@ -34,20 +41,69 @@ interface NavProps { registrationMode?: RegistrationMode | null } +function LanguageSwitcher() { + const locale = useLocale() as LocaleCode + const pathname = usePathname() + const searchParams = useSearchParams() + const copy = getPublicCopy(locale) + const [isOpen, setIsOpen] = useState(false) + + const changeLocale = (nextLocale: LocaleCode) => { + if (nextLocale === locale) { + setIsOpen(false) + return + } + + setIsOpen(false) + navigateToLocaleHref(buildLocaleSwitchHref(nextLocale, pathname, searchParams)) + } + + return ( + <DropdownMenu open={isOpen} onOpenChange={setIsOpen}> + <DropdownMenuTrigger asChild> + <Button + variant='outline' + size='sm' + className='rounded-md px-3 font-medium text-sm' + > + <LanguagesIcon className='h-4 w-4' /> + <span>{copy.localeNames[locale]}</span> + <ChevronDownIcon className='h-4 w-4' /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align='end' className='w-48'> + {locales.map((code) => ( + <DropdownMenuItem + key={code} + onSelect={() => { + changeLocale(code) + }} + className='flex items-center gap-2' + > + <span>{copy.localeNames[code]}</span> + {locale === code ? <Check className='ml-auto h-4 w-4 text-primary' /> : null} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + ) +} + export default function Nav({ hideAuthButtons = false, variant = 'landing', registrationMode = null, }: NavProps = {}) { const [githubStars, setGithubStars] = useState('0') - const router = useRouter() const brand = useBrandConfig() + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale) const hasResolvedRegistrationMode = registrationMode !== null const registrationPrimaryHref = registrationMode ? getRegistrationPrimaryHref(registrationMode) : null const registrationPrimaryLabel = registrationMode - ? getRegistrationPrimaryLabel(registrationMode) + ? getPrimaryRegistrationLabel(copy, registrationMode) : null const showStandaloneLogin = hasResolvedRegistrationMode && registrationPrimaryHref !== null @@ -72,31 +128,18 @@ export default function Nav({ return () => clearTimeout(timeoutId) }, [variant]) - const navigateToLogin = useCallback(() => { - router.push('/login?reauth=1') - }, [router]) - - const navigateToPrimaryCta = useCallback(() => { - if (!registrationPrimaryHref) { - return - } - - router.push(registrationPrimaryHref) - }, [registrationPrimaryHref, router]) - const desktopNavLinks = variant === 'landing' && ( <div className='hidden items-center gap-6 font-medium text-muted-foreground text-sm md:flex'> - <Link - href='https://docs.tradinggoose.ai' + <a + href={localizeDocsUrl(locale)} target='_blank' rel='noopener noreferrer' className='transition-colors hover:text-foreground' - prefetch={false} > - Docs - </Link> + {copy.nav.docs} + </a> <Link href='/blog' className='transition-colors hover:text-foreground' prefetch={false}> - Blog + {copy.nav.blog} </Link> <a href='https://github.com/TradingGoose/TradingGoose-Studio' @@ -115,23 +158,19 @@ export default function Nav({ !hideAuthButtons && hasResolvedRegistrationMode && registrationPrimaryLabel ? ( <> {showStandaloneLogin ? ( - <Button - variant='ghost' - size='sm' - onClick={navigateToLogin} - className='rounded-md text-base' - > - Login + <Button variant='ghost' size='sm' asChild className='rounded-md text-base'> + <Link href='/login?reauth=1'>{copy.nav.login}</Link> </Button> ) : null} - <Button - size='sm' - onClick={registrationPrimaryHref ? navigateToPrimaryCta : undefined} - disabled={!registrationPrimaryHref} - className='rounded-md text-base' - > - {registrationPrimaryLabel} - </Button> + {registrationPrimaryHref ? ( + <Button size='sm' asChild className='rounded-md text-base'> + <Link href={registrationPrimaryHref}>{registrationPrimaryLabel}</Link> + </Button> + ) : ( + <Button size='sm' disabled className='rounded-md text-base'> + {registrationPrimaryLabel} + </Button> + )} </> ) : null @@ -151,7 +190,7 @@ export default function Nav({ prefetch={false} > <span itemProp='name' className='sr-only'> - {brand.name} Home + {brand.name} {copy.nav.homeLabel} </span> <span className='flex items-center gap-2 font-semibold text-[18px] text-foreground tracking-tight' @@ -173,39 +212,41 @@ export default function Nav({ <div className='flex items-center gap-3 sm:gap-4'> {desktopNavLinks} + <LanguageSwitcher /> {variant === 'landing' && !hideAuthButtons && hasResolvedRegistrationMode ? ( <Separator orientation='vertical' className='hidden h-6 md:block' /> ) : null} - {registrationActions ? <div className='hidden items-center gap-2 md:flex'>{registrationActions}</div> : null} + {registrationActions ? ( + <div className='hidden items-center gap-2 md:flex'>{registrationActions}</div> + ) : null} {variant === 'landing' ? ( <DropdownMenu> <DropdownMenuTrigger className='md:hidden' asChild> <Button variant='outline' size='icon'> <MenuIcon className='h-5 w-5' /> - <span className='sr-only'>Menu</span> + <span className='sr-only'>{copy.nav.menu}</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent className='w-64' align='end'> <DropdownMenuGroup> - <DropdownMenuItem> - <Link - href='https://docs.tradinggoose.ai' + <DropdownMenuItem asChild> + <a + href={localizeDocsUrl(locale)} target='_blank' rel='noopener noreferrer' className='w-full' - prefetch={false} > - Docs - </Link> + {copy.nav.docs} + </a> </DropdownMenuItem> - <DropdownMenuItem> + <DropdownMenuItem asChild> <Link href='/blog' className='w-full' prefetch={false}> - Blog + {copy.nav.blog} </Link> </DropdownMenuItem> - <DropdownMenuItem> + <DropdownMenuItem asChild> <a href='https://github.com/TradingGoose/TradingGoose-Studio' target='_blank' diff --git a/apps/tradinggoose/app/(landing)/components/structured-data.tsx b/apps/tradinggoose/app/(landing)/components/structured-data.tsx index 7fd1e351e..47bc1f940 100644 --- a/apps/tradinggoose/app/(landing)/components/structured-data.tsx +++ b/apps/tradinggoose/app/(landing)/components/structured-data.tsx @@ -1,5 +1,8 @@ +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 { localizeUrl, locales, type LocaleCode } from '@/i18n/utils' interface GitHubStats { stars: number | null @@ -134,6 +137,8 @@ function buildStructuredOffers(catalog: Awaited<ReturnType<typeof getPublicBilli } export default async function StructuredData() { + const locale = (await getLocale()) as LocaleCode + const copy = getPublicCopy(locale) const [githubStats, billingCatalog] = await Promise.all([ fetchGitHubStats(), getPublicBillingCatalog(), @@ -154,9 +159,8 @@ export default async function StructuredData() { name: 'TradingGoose', alternateName: ['TradingGoose Studio', 'TradingGoose.ai'], legalName: 'TradingGoose Studio', - 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. It is a drag-and-drop workflow builder for custom indicators, live market monitors, and AI agent automations — not to be confused with the older TradingGoose multi-agent research framework.', - url: 'https://tradinggoose.ai', + description: copy.meta.landing.description, + url: localizeUrl('https://tradinggoose.ai', locale, '/'), foundingDate: '2026-04-04', knowsAbout: [ 'Algorithmic trading', @@ -186,17 +190,16 @@ export default async function StructuredData() { contactPoint: { '@type': 'ContactPoint', contactType: 'customer support', - availableLanguage: ['en'], + availableLanguage: locales, }, ...(interactionStatistic.length > 0 && { interactionStatistic }), }, { '@type': 'WebSite', '@id': 'https://tradinggoose.ai/#website', - url: 'https://tradinggoose.ai', - name: 'TradingGoose - Visual Workflow Platform for LLM Trading', - description: - 'Open-source platform for technical LLM-driven trading. Connect data providers, write custom indicators in PineTS, trigger AI agent workflows on market signals.', + url: localizeUrl('https://tradinggoose.ai', locale, '/'), + name: copy.meta.landing.title, + description: copy.meta.landing.description, publisher: { '@id': 'https://tradinggoose.ai/#organization', }, @@ -211,13 +214,13 @@ export default async function StructuredData() { 'query-input': 'required name=search_term_string', }, ], - inLanguage: 'en-US', + inLanguage: locale, }, { '@type': 'WebPage', '@id': 'https://tradinggoose.ai/#webpage', - url: 'https://tradinggoose.ai', - name: 'TradingGoose - Build your Trading Analysis with AI Agent Workflows', + url: localizeUrl('https://tradinggoose.ai', locale, '/'), + name: copy.meta.landing.title, isPartOf: { '@id': 'https://tradinggoose.ai/#website', }, @@ -226,12 +229,11 @@ export default async function StructuredData() { }, datePublished: '2025-01-01T00:00:00+00:00', dateModified: new Date().toISOString(), - description: - 'Build AI-powered trading analysis workflows with TradingGoose. Connect live data providers, write custom indicators, and deploy agents that trigger on market signals.', + description: copy.meta.landing.description, breadcrumb: { '@id': 'https://tradinggoose.ai/#breadcrumb', }, - inLanguage: 'en-US', + inLanguage: locale, speakable: { '@type': 'SpeakableSpecification', cssSelector: ['h1', 'h2', '.hero-description'], @@ -250,8 +252,8 @@ export default async function StructuredData() { { '@type': 'ListItem', position: 1, - name: 'Home', - item: 'https://tradinggoose.ai', + name: copy.nav.homeLabel, + item: localizeUrl('https://tradinggoose.ai', locale, '/'), }, ], }, @@ -283,10 +285,12 @@ export default async function StructuredData() { caption: 'TradingGoose visual trading workflow builder', }, ], + inLanguage: locale, }, { '@type': 'FAQPage', '@id': 'https://tradinggoose.ai/#faq', + inLanguage: locale, mainEntity: [ { '@type': 'Question', @@ -373,16 +377,14 @@ export default async function StructuredData() { { '@type': 'Article', '@id': 'https://tradinggoose.ai/#article-disambiguation', - headline: - 'TradingGoose Studio: open-source visual workflow platform for LLM-driven trading', - description: - 'Canonical reference page for TradingGoose Studio. This is the drag-and-drop workflow builder with PineTS custom indicators, live market monitors, and AI agent automation — distinct from the older TradingGoose multi-agent LLM research framework.', + headline: copy.meta.landing.title, + description: copy.meta.landing.description, author: { '@id': 'https://tradinggoose.ai/#organization' }, publisher: { '@id': 'https://tradinggoose.ai/#organization' }, mainEntityOfPage: { '@id': 'https://tradinggoose.ai/#webpage' }, datePublished: '2025-01-01T00:00:00+00:00', dateModified: new Date().toISOString(), - inLanguage: 'en-US', + inLanguage: locale, }, ], } diff --git a/apps/tradinggoose/app/(landing)/privacy/page.tsx b/apps/tradinggoose/app/(landing)/privacy/page.tsx index c6703f56c..4f222d7fc 100644 --- a/apps/tradinggoose/app/(landing)/privacy/page.tsx +++ b/apps/tradinggoose/app/(landing)/privacy/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from 'next' -import Link from 'next/link' -import LegalLayout from '@/app/(landing)/components/legal-layout' import { getBrandConfig } from '@/lib/branding/branding' +import LegalLayout from '@/app/(landing)/components/legal-layout' export const metadata: Metadata = { title: 'Privacy Policy | TradingGoose', @@ -266,12 +265,9 @@ export default function PrivacyPolicy() { </p> <p> To make a privacy-related request for a project-operated deployment, contact{' '} - <Link - href={supportEmailHref} - className='text-primary underline hover:text-primary-hover' - > + <a href={supportEmailHref} className='text-primary underline hover:text-primary-hover'> {supportEmail} - </Link> + </a> . If you use a self-hosted or third-party deployment, contact that operator instead. </p> </section> @@ -307,12 +303,9 @@ export default function PrivacyPolicy() { <p className='mb-4'> If you have questions, requests, or complaints regarding this Privacy Policy for a project-operated deployment, contact us at{' '} - <Link - href={supportEmailHref} - className='text-primary underline hover:text-primary-hover' - > + <a href={supportEmailHref} className='text-primary underline hover:text-primary-hover'> {supportEmail} - </Link> + </a> . </p> <p> diff --git a/apps/tradinggoose/app/admin/billing/billing-admin.tsx b/apps/tradinggoose/app/admin/billing/billing-admin.tsx index 7c0c545ad..1dabde03d 100644 --- a/apps/tradinggoose/app/admin/billing/billing-admin.tsx +++ b/apps/tradinggoose/app/admin/billing/billing-admin.tsx @@ -2,8 +2,6 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' import { Plus, Receipt } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/navigation' import { Alert, AlertDescription, @@ -18,6 +16,7 @@ import { } from '@/components/ui' import type { AdminBillingSettingsMutationInput } from '@/lib/admin/billing/settings-mutations' import type { AdminBillingTierSnapshot } from '@/lib/admin/billing/types' +import { Link, useRouter } from '@/i18n/navigation' import { ADMIN_META_BADGE_CLASSNAME } from '@/app/admin/badge-styles' import { AdminPageShell } from '@/app/admin/page-shell' import { diff --git a/apps/tradinggoose/app/admin/billing/billing-unavailable.test.tsx b/apps/tradinggoose/app/admin/billing/billing-unavailable.test.tsx new file mode 100644 index 000000000..e3155ec26 --- /dev/null +++ b/apps/tradinggoose/app/admin/billing/billing-unavailable.test.tsx @@ -0,0 +1,73 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { useLocaleMock } = vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'zh-CN'), +})) + +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { children?: React.ReactNode; href: string }) => { + const locale = useLocaleMock() + const localePrefix = locale === 'zh-CN' ? '/zh' : `/${locale}` + const localizedHref = + !href.startsWith('/') || href.startsWith(localePrefix) || locale === 'en' + ? href + : `${localePrefix}${href}` + + return ( + <a href={localizedHref} {...props}> + {children} + </a> + ) + }, +})) + +vi.mock('../page-shell', () => ({ + AdminPageShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, +})) + +describe('AdminBillingUnavailable', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + useLocaleMock.mockReturnValue('zh-CN') + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.restoreAllMocks() + }) + + it('localizes the back link', async () => { + const { AdminBillingUnavailable } = await import('./billing-unavailable') + + await act(async () => { + root.render( + <AdminBillingUnavailable title='Billing unavailable' description='Disabled' /> + ) + }) + + expect(container.querySelector('a[href="/zh/admin"]')).not.toBeNull() + }) +}) diff --git a/apps/tradinggoose/app/admin/billing/billing-unavailable.tsx b/apps/tradinggoose/app/admin/billing/billing-unavailable.tsx index 85459f0d9..6f5ea7952 100644 --- a/apps/tradinggoose/app/admin/billing/billing-unavailable.tsx +++ b/apps/tradinggoose/app/admin/billing/billing-unavailable.tsx @@ -1,9 +1,9 @@ -import Link from 'next/link' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { ADMIN_META_BADGE_CLASSNAME } from '../badge-styles' import { AdminPageShell } from '../page-shell' +import { Link } from '@/i18n/navigation' export function AdminBillingUnavailable({ title, diff --git a/apps/tradinggoose/app/admin/billing/tier-detail.test.tsx b/apps/tradinggoose/app/admin/billing/tier-detail.test.tsx new file mode 100644 index 000000000..21c42fa07 --- /dev/null +++ b/apps/tradinggoose/app/admin/billing/tier-detail.test.tsx @@ -0,0 +1,152 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AdminBillingTierDetail } from './tier-detail' + +const mockPush = vi.fn() +const { useLocaleMock } = vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'zh-CN'), +})) + +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { children?: React.ReactNode; href: string }) => { + const locale = useLocaleMock() + const localePrefix = locale === 'zh-CN' ? '/zh' : `/${locale}` + const localizedHref = + !href.startsWith('/') || href.startsWith(localePrefix) || locale === 'en' + ? href + : `${localePrefix}${href}` + + return ( + <a href={localizedHref} {...props}> + {children} + </a> + ) + }, + useRouter: () => ({ + push: (href: string) => { + const locale = useLocaleMock() + const localePrefix = locale === 'zh-CN' ? '/zh' : `/${locale}` + const localizedHref = + !href.startsWith('/') || href.startsWith(localePrefix) || locale === 'en' + ? href + : `${localePrefix}${href}` + mockPush(localizedHref) + }, + }), +})) + +vi.mock('@/components/ui', () => ({ + Alert: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + AlertDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + Button: ({ + children, + onClick, + }: { + children?: React.ReactNode + onClick?: () => void + }) => <button onClick={onClick}>{children}</button>, +})) + +vi.mock('./tier-editor', () => ({ + BillingBreadcrumbs: () => <div data-testid='breadcrumbs' />, + buildTierMutationInput: vi.fn(), + createTierFormDefaults: vi.fn(), + createTierPreviewState: vi.fn(), + DEFAULT_TIER_EDITOR_SECTIONS: { + general: true, + pricing: true, + access: true, + seats: false, + limits: true, + metering: false, + }, + getErrorMessage: (error: unknown) => + error instanceof Error ? error.message : 'Something went wrong', + normalizeTierFormDefaults: vi.fn((value: unknown) => value), + TierEditorFormSurface: () => null, + TierEditorHeaderCenter: () => null, +})) + +vi.mock('@/hooks/queries/admin-billing', () => ({ + useAdminBillingSnapshot: () => ({ + data: { + currentTiers: [], + }, + isPending: false, + isError: false, + error: null, + }), + useDeleteAdminBillingTier: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), + useUpdateAdminBillingTier: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})) + +vi.mock('@/app/admin/page-shell', () => ({ + AdminPageShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, +})) + +vi.mock('@/app/workspace/[workspaceId]/knowledge/components', () => ({ + EmptyStateCard: ({ + buttonText, + onClick, + }: { + buttonText: string + onClick: () => void + }) => <button onClick={onClick}>{buttonText}</button>, + PrimaryButton: ({ children }: { children: React.ReactNode }) => <button>{children}</button>, +})) + +describe('AdminBillingTierDetail', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mockPush.mockReset() + useLocaleMock.mockReturnValue('zh-CN') + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.restoreAllMocks() + }) + + it('localizes the back button route', () => { + act(() => { + root.render(<AdminBillingTierDetail tierId='missing-tier' />) + }) + + const button = container.querySelector('button') + expect(button?.textContent).toContain('Back to Billing') + + act(() => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mockPush).toHaveBeenCalledWith('/zh/admin/billing') + }) +}) diff --git a/apps/tradinggoose/app/admin/billing/tier-detail.tsx b/apps/tradinggoose/app/admin/billing/tier-detail.tsx index d5b054bc2..4e54cea17 100644 --- a/apps/tradinggoose/app/admin/billing/tier-detail.tsx +++ b/apps/tradinggoose/app/admin/billing/tier-detail.tsx @@ -2,9 +2,9 @@ import { type FormEvent, useMemo, useState } from 'react' import { Receipt } from 'lucide-react' -import { useRouter } from 'next/navigation' import { Alert, AlertDescription, Button } from '@/components/ui' import type { AdminBillingTierSnapshot } from '@/lib/admin/billing/types' +import { useRouter } from '@/i18n/navigation' import { AdminPageShell } from '@/app/admin/page-shell' import { EmptyStateCard, PrimaryButton } from '@/app/workspace/[workspaceId]/knowledge/components' import { diff --git a/apps/tradinggoose/app/admin/billing/tier-editor.tsx b/apps/tradinggoose/app/admin/billing/tier-editor.tsx index 1c9e6d65e..4a17fcab3 100644 --- a/apps/tradinggoose/app/admin/billing/tier-editor.tsx +++ b/apps/tradinggoose/app/admin/billing/tier-editor.tsx @@ -2,7 +2,6 @@ import type { FormEvent, ReactNode } from 'react' import { ChevronDown, ChevronRight, ShieldCheck } from 'lucide-react' -import Link from 'next/link' import { Badge, Button, @@ -21,6 +20,7 @@ import { } from '@/components/ui' import type { AdminBillingTierMutationInput } from '@/lib/admin/billing/tier-mutations' import type { AdminBillingTierSnapshot } from '@/lib/admin/billing/types' +import { Link } from '@/i18n/navigation' import { cn } from '@/lib/utils' import { ADMIN_META_BADGE_CLASSNAME, ADMIN_STATUS_BADGE_CLASSNAME } from '@/app/admin/badge-styles' diff --git a/apps/tradinggoose/app/admin/page.test.tsx b/apps/tradinggoose/app/admin/page.test.tsx new file mode 100644 index 000000000..295cf5786 --- /dev/null +++ b/apps/tradinggoose/app/admin/page.test.tsx @@ -0,0 +1,86 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { useLocaleMock } = vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'zh-CN'), +})) + +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { children?: React.ReactNode; href: string }) => { + const locale = useLocaleMock() + const localePrefix = locale === 'zh-CN' ? '/zh' : `/${locale}` + const localizedHref = + !href.startsWith('/') || href.startsWith(localePrefix) || locale === 'en' + ? href + : `${localePrefix}${href}` + + return ( + <a href={localizedHref} {...props}> + {children} + </a> + ) + }, +})) + +vi.mock('@/lib/billing/settings', () => ({ + getBillingGateState: vi.fn(async () => ({ + stripeConfigured: true, + })), +})) + +vi.mock('./page-shell', () => ({ + AdminPageShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, +})) + +vi.mock('./system-settings-section', () => ({ + AdminSystemSettingsSection: () => <div data-testid='system-settings' />, +})) + +describe('AdminHomePage', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + useLocaleMock.mockReturnValue('zh-CN') + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.restoreAllMocks() + }) + + it('localizes admin landing links', async () => { + const AdminHomePage = (await import('./page')).default + + const element = await AdminHomePage() + + await act(async () => { + root.render(element as React.ReactElement) + }) + + expect(container.querySelector('a[href="/zh/admin/billing"]')).not.toBeNull() + expect(container.querySelector('a[href="/zh/admin/services"]')).not.toBeNull() + expect(container.querySelector('a[href="/zh/admin/integrations"]')).not.toBeNull() + expect(container.querySelector('a[href="/zh/admin/registration"]')).not.toBeNull() + }) +}) diff --git a/apps/tradinggoose/app/admin/page.tsx b/apps/tradinggoose/app/admin/page.tsx index 167237993..ca19d7fa5 100644 --- a/apps/tradinggoose/app/admin/page.tsx +++ b/apps/tradinggoose/app/admin/page.tsx @@ -1,8 +1,8 @@ -import Link from 'next/link' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { getBillingGateState } from '@/lib/billing/settings' +import { Link } from '@/i18n/navigation' import { ADMIN_META_BADGE_CLASSNAME } from './badge-styles' import { AdminPageShell } from './page-shell' import { AdminSystemSettingsSection } from './system-settings-section' diff --git a/apps/tradinggoose/app/api/auth/[...all]/route.test.ts b/apps/tradinggoose/app/api/auth/[...all]/route.test.ts index ab0530d24..75509a713 100644 --- a/apps/tradinggoose/app/api/auth/[...all]/route.test.ts +++ b/apps/tradinggoose/app/api/auth/[...all]/route.test.ts @@ -13,8 +13,8 @@ const { mockAuthHandler: vi.fn(), mockLoadSystemOAuthClientCredentials: vi.fn(), mockRunWithSystemOAuthClientCredentials: vi.fn(), - mockIsSignInOAuthProviderId: vi.fn((providerId: string) => - providerId === 'github' || providerId === 'google' + mockIsSignInOAuthProviderId: vi.fn( + (providerId: string) => providerId === 'github' || providerId === 'google' ), })) @@ -107,6 +107,54 @@ describe('/api/auth/[...all] route', () => { expect(mockAuthHandler).toHaveBeenCalledTimes(1) }) + it('hydrates Alpaca paper credentials before delegating OAuth link routes', async () => { + mockAuthHandler.mockResolvedValue(new Response(null, { status: 204 })) + mockLoadSystemOAuthClientCredentials.mockResolvedValue({ + 'alpaca-paper': { + clientId: 'client-id', + clientSecret: 'client-secret', + }, + }) + + const { handleAuthRequest } = await import('./route') + const response = await handleAuthRequest( + new Request('http://localhost/api/auth/oauth2/link', { + method: 'POST', + body: JSON.stringify({ + providerId: 'alpaca-paper', + callbackURL: 'http://localhost/workspace', + }), + }) + ) + + expect(response.status).toBe(204) + expect(mockLoadSystemOAuthClientCredentials).toHaveBeenCalledWith(['alpaca-paper']) + expect(mockRunWithSystemOAuthClientCredentials).toHaveBeenCalledTimes(1) + expect(mockAuthHandler).toHaveBeenCalledTimes(1) + }) + + it('hydrates Alpaca paper credentials before delegating OAuth callback routes', async () => { + mockAuthHandler.mockResolvedValue(new Response(null, { status: 204 })) + mockLoadSystemOAuthClientCredentials.mockResolvedValue({ + 'alpaca-paper': { + clientId: 'client-id', + clientSecret: 'client-secret', + }, + }) + + const { handleAuthRequest } = await import('./route') + const response = await handleAuthRequest( + new Request('http://localhost/api/auth/oauth2/callback/alpaca-paper?code=code', { + method: 'GET', + }) + ) + + expect(response.status).toBe(204) + expect(mockLoadSystemOAuthClientCredentials).toHaveBeenCalledWith(['alpaca-paper']) + expect(mockRunWithSystemOAuthClientCredentials).toHaveBeenCalledTimes(1) + expect(mockAuthHandler).toHaveBeenCalledTimes(1) + }) + it('returns 400 when a system oauth callback provider is not configured', async () => { const { handleAuthRequest } = await import('./route') const response = await handleAuthRequest( diff --git a/apps/tradinggoose/app/api/auth/oauth/connections/route.ts b/apps/tradinggoose/app/api/auth/oauth/connections/route.ts index 6da972088..b68b4c196 100644 --- a/apps/tradinggoose/app/api/auth/oauth/connections/route.ts +++ b/apps/tradinggoose/app/api/auth/oauth/connections/route.ts @@ -90,45 +90,22 @@ export async function GET(request: NextRequest) { displayName = `${acc.accountId} (${baseProvider})` } - // Create a unique connection key that includes the full provider ID const connectionKey = acc.providerId - // Find existing connection for this specific provider ID - const existingConnection = connections.find((conn) => conn.provider === connectionKey) - - const accountSummary = { - id: acc.id, - name: displayName, - } - - if (existingConnection) { - // Add account to existing connection - existingConnection.accounts = existingConnection.accounts || [] - existingConnection.accounts.push(accountSummary) - existingConnection.scopes = Array.from( - new Set([...(existingConnection.scopes || []), ...scopes]) - ) - - const existingTimestamp = existingConnection.lastConnected - ? new Date(existingConnection.lastConnected).getTime() - : 0 - const candidateTimestamp = acc.updatedAt.getTime() - - if (candidateTimestamp > existingTimestamp) { - existingConnection.lastConnected = acc.updatedAt.toISOString() - } - } else { - // Create new connection - connections.push({ - provider: connectionKey, - baseProvider, - featureType, - isConnected: true, - scopes, - lastConnected: acc.updatedAt.toISOString(), - accounts: [accountSummary], - }) - } + connections.push({ + provider: connectionKey, + baseProvider, + featureType, + isConnected: true, + scopes, + lastConnected: acc.updatedAt.toISOString(), + accounts: [ + { + id: acc.id, + name: displayName, + }, + ], + }) } } diff --git a/apps/tradinggoose/app/api/auth/oauth/credentials/route.ts b/apps/tradinggoose/app/api/auth/oauth/credentials/route.ts index 625b89198..2e2314765 100644 --- a/apps/tradinggoose/app/api/auth/oauth/credentials/route.ts +++ b/apps/tradinggoose/app/api/auth/oauth/credentials/route.ts @@ -5,12 +5,7 @@ import { jwtDecode } from 'jwt-decode' import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - OAUTH_PROVIDERS, - type OAuthProvider, - type OAuthService, - parseProvider, -} from '@/lib/oauth' +import { OAUTH_PROVIDERS, type OAuthProvider, type OAuthService, parseProvider } from '@/lib/oauth' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' @@ -45,6 +40,7 @@ function toCredentialResponse( id: acc.id, name: displayName, provider: acc.providerId, + serviceId: featureType, lastUsed: acc.updatedAt.toISOString(), isDefault, scopes, diff --git a/apps/tradinggoose/app/api/auth/oauth/disconnect/route.test.ts b/apps/tradinggoose/app/api/auth/oauth/disconnect/route.test.ts index 5eca47f3d..c673d2e4f 100644 --- a/apps/tradinggoose/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/tradinggoose/app/api/auth/oauth/disconnect/route.test.ts @@ -101,29 +101,6 @@ describe('OAuth Disconnect API Route', () => { expect(mockLogger.info).toHaveBeenCalled() }) - it('should disconnect a specific account row successfully', async () => { - mockGetSession.mockResolvedValueOnce({ - user: { id: 'user-123' }, - }) - - mockDb.delete.mockReturnValueOnce(mockDb) - mockDb.where.mockResolvedValueOnce(undefined) - - const req = createMockRequest('POST', { - provider: 'google', - accountId: 'account-123', - }) - - const { POST } = await import('@/app/api/auth/oauth/disconnect/route') - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(mockLogger.info).toHaveBeenCalled() - }) - it('should handle unauthenticated user', async () => { mockGetSession.mockResolvedValueOnce(null) diff --git a/apps/tradinggoose/app/api/auth/oauth/disconnect/route.ts b/apps/tradinggoose/app/api/auth/oauth/disconnect/route.ts index 5bbe87f6f..27a1c0719 100644 --- a/apps/tradinggoose/app/api/auth/oauth/disconnect/route.ts +++ b/apps/tradinggoose/app/api/auth/oauth/disconnect/route.ts @@ -26,8 +26,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) } - // Get the provider, providerId, and accountId from the request body - const { provider, providerId, accountId } = await request.json() + const { provider, providerId } = await request.json() if (!provider) { logger.warn(`[${requestId}] Missing provider in disconnect request`) @@ -39,13 +38,7 @@ export async function POST(request: NextRequest) { hasProviderId: !!providerId, }) - // If a specific account row ID is provided, delete that exact account - if (accountId) { - await db - .delete(account) - .where(and(eq(account.userId, session.user.id), eq(account.id, accountId))) - } else if (providerId) { - // If a specific providerId is provided, delete accounts for that provider ID + if (providerId) { await db .delete(account) .where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId))) diff --git a/apps/tradinggoose/app/api/auth/oauth/token/route.ts b/apps/tradinggoose/app/api/auth/oauth/token/route.ts index 174c28d12..780d2d731 100644 --- a/apps/tradinggoose/app/api/auth/oauth/token/route.ts +++ b/apps/tradinggoose/app/api/auth/oauth/token/route.ts @@ -52,7 +52,12 @@ export async function POST(request: NextRequest) { const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) const apiKey = credential.providerId === 'trello' ? await getTrelloApiKey() : undefined return NextResponse.json( - { accessToken, idToken: credential.idToken || undefined, apiKey }, + { + accessToken, + idToken: credential.idToken || undefined, + apiKey, + providerId: credential.providerId, + }, { status: 200 } ) } catch (error) { @@ -103,7 +108,12 @@ export async function GET(request: NextRequest) { const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) const apiKey = credential.providerId === 'trello' ? await getTrelloApiKey() : undefined return NextResponse.json( - { accessToken, idToken: credential.idToken || undefined, apiKey }, + { + accessToken, + idToken: credential.idToken || undefined, + apiKey, + providerId: credential.providerId, + }, { status: 200 } ) } catch (_error) { diff --git a/apps/tradinggoose/app/api/auth/oauth/utils.test.ts b/apps/tradinggoose/app/api/auth/oauth/utils.test.ts index eecc4d54b..a170ff919 100644 --- a/apps/tradinggoose/app/api/auth/oauth/utils.test.ts +++ b/apps/tradinggoose/app/api/auth/oauth/utils.test.ts @@ -131,6 +131,100 @@ describe('OAuth Utils', () => { }) }) + describe('getOAuthToken', () => { + it('should return a valid access token for a single provider connection', async () => { + mockDb.limit.mockReturnValueOnce([ + { + id: 'credential-id', + accessToken: 'valid-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + }, + ]) + + const { getOAuthToken } = await import('@/app/api/auth/oauth/utils') + + const token = await getOAuthToken('test-user-id', 'alpaca') + + expect(token).toBe('valid-token') + expect(mockDb.limit).toHaveBeenCalledWith(2) + expect(mockDb.orderBy).not.toHaveBeenCalled() + }) + + it('should return null when no provider connection exists', async () => { + mockDb.limit.mockReturnValueOnce([]) + + const { getOAuthToken } = await import('@/app/api/auth/oauth/utils') + + const token = await getOAuthToken('test-user-id', 'alpaca') + + expect(token).toBeNull() + expect(mockLogger.warn).toHaveBeenCalledWith( + 'No OAuth token found for user test-user-id, provider alpaca' + ) + }) + + it('should reject duplicate provider connections instead of choosing one', async () => { + mockDb.limit.mockReturnValueOnce([ + { + id: 'credential-id-1', + accessToken: 'first-token', + refreshToken: 'refresh-token-1', + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + }, + { + id: 'credential-id-2', + accessToken: 'second-token', + refreshToken: 'refresh-token-2', + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + }, + ]) + + const { getOAuthToken } = await import('@/app/api/auth/oauth/utils') + + const token = await getOAuthToken('test-user-id', 'alpaca') + + expect(token).toBeNull() + expect(mockRefreshOAuthToken).not.toHaveBeenCalled() + expect(mockLogger.error).toHaveBeenCalledWith( + 'Multiple OAuth connections found for user test-user-id, provider alpaca', + { + providerId: 'alpaca', + userId: 'test-user-id', + } + ) + }) + + it('should refresh an expired token for a single provider connection', async () => { + mockDb.limit.mockReturnValueOnce([ + { + id: 'credential-id', + accessToken: 'expired-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + }, + ]) + mockRefreshOAuthToken.mockResolvedValueOnce({ + accessToken: 'new-token', + expiresIn: 3600, + refreshToken: 'new-refresh-token', + }) + + const { getOAuthToken } = await import('@/app/api/auth/oauth/utils') + + const token = await getOAuthToken('test-user-id', 'alpaca') + + expect(mockRefreshOAuthToken).toHaveBeenCalledWith('alpaca', 'refresh-token') + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'new-token', + refreshToken: 'new-refresh-token', + }) + ) + expect(token).toBe('new-token') + }) + }) + describe('refreshTokenIfNeeded', () => { it('should return valid token without refresh if not expired', async () => { const mockCredential = { diff --git a/apps/tradinggoose/app/api/auth/oauth/utils.ts b/apps/tradinggoose/app/api/auth/oauth/utils.ts index b6d059a22..3e1cd9868 100644 --- a/apps/tradinggoose/app/api/auth/oauth/utils.ts +++ b/apps/tradinggoose/app/api/auth/oauth/utils.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { account, workflow } from '@tradinggoose/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { @@ -128,15 +128,21 @@ export async function getOAuthToken(userId: string, providerId: string): Promise }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) - // Always use the most recently updated credential for this provider - .orderBy(desc(account.updatedAt)) - .limit(1) + .limit(2) if (connections.length === 0) { logger.warn(`No OAuth token found for user ${userId}, provider ${providerId}`) return null } + if (connections.length > 1) { + logger.error(`Multiple OAuth connections found for user ${userId}, provider ${providerId}`, { + providerId, + userId, + }) + return null + } + const credential = connections[0] // Determine whether we should refresh: missing token OR expired token diff --git a/apps/tradinggoose/app/api/indicators/execute/route.ts b/apps/tradinggoose/app/api/indicators/execute/route.ts deleted file mode 100644 index 1a1a082c3..000000000 --- a/apps/tradinggoose/app/api/indicators/execute/route.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { db } from '@tradinggoose/db' -import { pineIndicators } from '@tradinggoose/db/schema' -import { and, eq, inArray } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { - ExecutionGateError, - enforceServerExecutionRateLimit, - getExecutionConcurrencyLimitMessage, - isExecutionConcurrencyBackendUnavailableError, - isExecutionConcurrencyLimitError, - withExecutionConcurrencyLimit, -} from '@/lib/execution/execution-concurrency-limit' -import { getLocalVmSaturationLimitMessage, isLocalVmSaturationLimitError } from '@/lib/execution/local-saturation-limit' -import { DEFAULT_INDICATOR_RUNTIME_MAP } from '@/lib/indicators/default/runtime' -import { executeCompiledIndicator } from '@/lib/indicators/execution/compile-execution' -import { buildInputsMapFromMeta, normalizeInputMetaMap } from '@/lib/indicators/input-meta' -import { mapMarketSeriesToBarsMs } from '@/lib/indicators/series-data' -import { toListingValueObject } from '@/lib/listing/identity' -import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' -import { RateLimitError } from '@/services/queue' -import { - authenticateIndicatorRequest, - getWorkspaceWritePermissionError, - isExecutionTimeoutError, - parseIndicatorRequestBody, -} from '../utils' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 30 - -const logger = createLogger('IndicatorExecuteAPI') -const EXECUTION_TIMEOUT_MS = 15000 - -type IndicatorExecuteWarning = { - code: string - message: string -} - -type ExecuteResult = { - indicatorId: string - output: unknown | null - warnings: IndicatorExecuteWarning[] - unsupported: unknown - counts: { plots: number; markers: number; triggers: number } - executionError?: { message: string; code: string; unsupported?: unknown } -} - -const MarketBarSchema = z.object({ - timeStamp: z.string(), - open: z.number().optional(), - high: z.number().optional(), - low: z.number().optional(), - close: z.number(), - volume: z.number().optional(), - turnover: z.number().optional(), -}) - -const ListingIdentitySchema = z.object({ - listing_id: z.string(), - base_id: z.string(), - quote_id: z.string(), - listing_type: z.enum(['default', 'crypto', 'currency']), -}) - -const MarketSeriesSchema = z.object({ - listing: ListingIdentitySchema.nullable().optional(), - bars: z.array(MarketBarSchema).min(1, 'marketSeries.bars is required'), -}) - -const ExecuteSchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId is required'), - indicatorIds: z.array(z.string().min(1)).min(1, 'indicatorIds is required'), - marketSeries: MarketSeriesSchema, - interval: z.string().optional(), - intervalMs: z.number().optional(), - inputsMapById: z.record(z.record(z.any())).optional(), -}) - -export async function POST(request: NextRequest) { - const requestId = generateRequestId() - - try { - const auth = await authenticateIndicatorRequest({ - request, - requestId, - logger, - action: 'execute', - }) - if ('response' in auth) return auth.response - - const parsedBody = await parseIndicatorRequestBody({ request, schema: ExecuteSchema }) - if ('response' in parsedBody) return parsedBody.response - - const { workspaceId, indicatorIds, interval, intervalMs } = parsedBody.data - - const permissionError = await getWorkspaceWritePermissionError(auth.userId, workspaceId) - if (permissionError) return permissionError - - await enforceServerExecutionRateLimit({ - actorUserId: auth.userId, - authType: auth.authType, - workspaceId, - isAsync: false, - logger, - requestId, - source: 'indicator execute', - }) - - const marketSeries = parsedBody.data.marketSeries - const barsMs = mapMarketSeriesToBarsMs(marketSeries, intervalMs ?? null) - const executionListing = toListingValueObject(marketSeries.listing ?? null) - - const customIndicatorIds = indicatorIds.filter((id) => !DEFAULT_INDICATOR_RUNTIME_MAP.has(id)) - const storedIndicators = - customIndicatorIds.length > 0 - ? await db - .select() - .from(pineIndicators) - .where( - and( - eq(pineIndicators.workspaceId, workspaceId), - inArray(pineIndicators.id, customIndicatorIds) - ) - ) - : [] - - const indicatorMap = new Map(storedIndicators.map((indicator) => [indicator.id, indicator])) - - const results = await withExecutionConcurrencyLimit({ - userId: auth.userId, - workspaceId, - task: async () => { - const nextResults: ExecuteResult[] = [] - - for (const indicatorId of indicatorIds) { - const customIndicator = indicatorMap.get(indicatorId) - const defaultIndicator = DEFAULT_INDICATOR_RUNTIME_MAP.get(indicatorId) - - if (!customIndicator && !defaultIndicator) { - nextResults.push({ - indicatorId, - output: null, - warnings: [{ code: 'missing_indicator', message: `${indicatorId} is missing.` }], - unsupported: { plots: [], styles: [] }, - counts: { plots: 0, markers: 0, triggers: 0 }, - executionError: { message: 'Indicator not found', code: 'missing_indicator' }, - }) - continue - } - - const pineCode = customIndicator?.pineCode ?? defaultIndicator?.pineCode ?? '' - const inputMeta = customIndicator - ? normalizeInputMetaMap(customIndicator.inputMeta) - : defaultIndicator?.inputMeta - const inputsOverride = parsedBody.data.inputsMapById?.[indicatorId] - const baseInputsMap = buildInputsMapFromMeta(inputMeta) - const inputsMap = inputsOverride ? { ...baseInputsMap, ...inputsOverride } : baseInputsMap - - try { - const compiled = await executeCompiledIndicator({ - pineCode, - barsMs, - inputsMap, - listing: executionListing, - interval, - intervalMs, - executionTimeoutMs: EXECUTION_TIMEOUT_MS, - userId: auth.userId, - }) - - if (compiled.unsupportedFeatures && compiled.unsupportedFeatures.length > 0) { - nextResults.push({ - indicatorId, - output: null, - warnings: compiled.warnings, - unsupported: { plots: [], styles: [] }, - counts: { plots: 0, markers: 0, triggers: 0 }, - executionError: { - message: `${compiled.unsupportedFeatures[0]} is not supported`, - code: 'unsupported_feature', - unsupported: { features: compiled.unsupportedFeatures }, - }, - }) - continue - } - - if (!compiled.output) { - nextResults.push({ - indicatorId, - output: null, - warnings: compiled.warnings, - unsupported: compiled.unsupported ?? { plots: [], styles: [] }, - counts: { plots: 0, markers: 0, triggers: 0 }, - executionError: { - message: compiled.executionError?.message ?? 'Failed to execute indicator', - code: 'runtime_error', - }, - }) - continue - } - - const output = compiled.output - nextResults.push({ - indicatorId, - output, - warnings: compiled.warnings, - unsupported: output.unsupported, - counts: { - plots: output.series.length, - markers: output.markers.length, - triggers: output.triggers.length, - }, - }) - } catch (error) { - if (isLocalVmSaturationLimitError(error)) { - throw error - } - - const timedOut = isExecutionTimeoutError(error) - nextResults.push({ - indicatorId, - output: null, - warnings: [], - unsupported: { plots: [], styles: [] }, - counts: { plots: 0, markers: 0, triggers: 0 }, - executionError: { - message: timedOut ? 'Execution timed out' : 'Failed to execute indicator', - code: timedOut ? 'timeout' : 'runtime_error', - }, - }) - } - } - - return nextResults - }, - }) - - return NextResponse.json({ success: true, data: results }) - } catch (error) { - if (error instanceof ExecutionGateError) { - return NextResponse.json( - { - success: false, - error: error.message, - code: 'usage_limit_exceeded', - }, - { status: error.statusCode } - ) - } - - if (error instanceof RateLimitError) { - return NextResponse.json( - { - success: false, - error: error.message, - code: 'rate_limit_exceeded', - }, - { status: error.statusCode } - ) - } - - if (isExecutionConcurrencyLimitError(error)) { - return NextResponse.json( - { - success: false, - error: getExecutionConcurrencyLimitMessage(error), - code: 'execution_concurrency_limit_exceeded', - }, - { status: error.statusCode } - ) - } - - if (isExecutionConcurrencyBackendUnavailableError(error)) { - return NextResponse.json( - { - success: false, - error: error.message, - code: 'execution_limiter_unavailable', - }, - { status: error.statusCode } - ) - } - - if (isLocalVmSaturationLimitError(error)) { - return NextResponse.json( - { - success: false, - error: getLocalVmSaturationLimitMessage(error), - code: 'engine_capacity_exceeded', - }, - { status: error.statusCode } - ) - } - - logger.error(`[${requestId}] Indicator execute failed`, { error }) - return NextResponse.json( - { success: false, error: 'Failed to execute indicators' }, - { status: 500 } - ) - } -} diff --git a/apps/tradinggoose/app/api/markdown/route.test.ts b/apps/tradinggoose/app/api/markdown/route.test.ts new file mode 100644 index 000000000..80eeb8909 --- /dev/null +++ b/apps/tradinggoose/app/api/markdown/route.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockRenderPublicPageMarkdown = vi.fn() +const mockGetAccurateTokenCount = vi.fn() + +vi.mock('@/lib/markdown/public-page-markdown', () => ({ + renderPublicPageMarkdown: mockRenderPublicPageMarkdown, +})) + +vi.mock('@/lib/tokenization/estimators', () => ({ + getAccurateTokenCount: mockGetAccurateTokenCount, +})) + +function createRequest(path: string, method: 'GET' | 'HEAD') { + return new NextRequest(`http://localhost:3000/api/markdown?path=${encodeURIComponent(path)}`, { + method, + }) +} + +describe('/api/markdown route', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + + mockRenderPublicPageMarkdown.mockResolvedValue('markdown body') + mockGetAccurateTokenCount.mockReturnValue(2) + }) + + it.each(['/es', '/zh'] as const)( + 'appends discovery links for GET homepage requests at %s', + async (path) => { + const { GET } = await import('./route') + const response = await GET(createRequest(path, 'GET')) + + expect(response.status).toBe(200) + expect(mockRenderPublicPageMarkdown).toHaveBeenCalledWith('http://localhost:3000', path) + + const linkHeader = response.headers.get('Link') ?? '' + expect(linkHeader).toContain('rel="api-catalog"') + expect(linkHeader).toContain('rel="service-doc"') + expect(linkHeader).toContain('rel="describedby"') + } + ) + + it.each(['/es', '/zh'] as const)( + 'appends discovery links for HEAD homepage requests at %s', + async (path) => { + const { HEAD } = await import('./route') + const response = await HEAD(createRequest(path, 'HEAD')) + + expect(response.status).toBe(200) + expect(mockRenderPublicPageMarkdown).toHaveBeenCalledWith('http://localhost:3000', path) + + const linkHeader = response.headers.get('Link') ?? '' + expect(linkHeader).toContain('rel="api-catalog"') + expect(linkHeader).toContain('rel="service-doc"') + expect(linkHeader).toContain('rel="describedby"') + } + ) +}) diff --git a/apps/tradinggoose/app/api/markdown/route.ts b/apps/tradinggoose/app/api/markdown/route.ts index 716fc7b6f..1f15bc646 100644 --- a/apps/tradinggoose/app/api/markdown/route.ts +++ b/apps/tradinggoose/app/api/markdown/route.ts @@ -8,11 +8,23 @@ import { } from '@/lib/markdown/negotiation' import { renderPublicPageMarkdown } from '@/lib/markdown/public-page-markdown' import { getAccurateTokenCount } from '@/lib/tokenization/estimators' +import { stripLocaleFromPathname } from '@/i18n/utils' async function createMarkdownResponse(request: NextRequest, includeBody: boolean) { const pathname = normalizeMarkdownPath(request.nextUrl.searchParams.get('path')) - if (!pathname || !isMarkdownRenderablePath(pathname)) { + if (!pathname) { + return new Response('Not found', { + status: 404, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + }, + }) + } + + const { locale, pathname: normalizedPathname } = stripLocaleFromPathname(pathname) + + if (!isMarkdownRenderablePath(normalizedPathname)) { return new Response('Not found', { status: 404, headers: { @@ -41,8 +53,8 @@ async function createMarkdownResponse(request: NextRequest, includeBody: boolean 'x-markdown-tokens': String(tokenCount), }) - if (pathname === '/') { - appendHomepageDiscoveryLinks(headers) + if (normalizedPathname === '/') { + appendHomepageDiscoveryLinks(headers, locale) } return new Response(includeBody ? markdown : null, { headers }) diff --git a/apps/tradinggoose/app/api/market/api-keys/shared.ts b/apps/tradinggoose/app/api/market/api-keys/shared.ts index 958deca80..946d34cd4 100644 --- a/apps/tradinggoose/app/api/market/api-keys/shared.ts +++ b/apps/tradinggoose/app/api/market/api-keys/shared.ts @@ -1,21 +1,16 @@ -import { MARKET_API_URL_DEFAULT, MARKET_API_VERSION } from '@/lib/market/client/constants' -import { resolveMarketApiServiceConfig } from '@/lib/system-services/runtime' +import { MARKET_API_VERSION } from '@/lib/market/client/constants' +import { requestTradingGooseMarket } from '@/lib/market/request-gate' type RemoteServiceKey = { id: string apiKey: string } -async function createRequestInit(body?: Record<string, unknown>): Promise<RequestInit> { - const marketApi = await resolveMarketApiServiceConfig() +function createRequestInit(body?: Record<string, unknown>): RequestInit { const headers: Record<string, string> = { 'Content-Type': 'application/json', } - if (marketApi.apiKey) { - headers['x-api-key'] = marketApi.apiKey - } - return { method: 'POST', headers, @@ -23,13 +18,8 @@ async function createRequestInit(body?: Record<string, unknown>): Promise<Reques } } -async function getMarketApiUrl(endpoint: string) { - const marketApi = await resolveMarketApiServiceConfig() - return new URL(endpoint, marketApi.baseUrl || MARKET_API_URL_DEFAULT).toString() -} - export async function proxyMarketApiKeysRequest(endpoint: string, body?: Record<string, unknown>) { - return fetch(await getMarketApiUrl(endpoint), await createRequestInit(body)) + return requestTradingGooseMarket(endpoint, createRequestInit(body)) } export function maskServiceKeys(apiKeys: RemoteServiceKey[]) { diff --git a/apps/tradinggoose/app/api/market/proxy.test.ts b/apps/tradinggoose/app/api/market/proxy.test.ts index 54efe6ca6..6d9bbf6f0 100644 --- a/apps/tradinggoose/app/api/market/proxy.test.ts +++ b/apps/tradinggoose/app/api/market/proxy.test.ts @@ -37,7 +37,7 @@ vi.mock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => mockLogger), })) -describe('market proxy search cache', () => { +describe('market proxy TradingGoose-Market gate', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() @@ -51,8 +51,8 @@ describe('market proxy search cache', () => { it('returns cached search responses before hitting the market service', async () => { mockReadServerJsonCache.mockResolvedValue({ body: '{"data":[{"listing_id":"AAPL"}]}', + headers: [['content-type', 'application/json']], status: 200, - contentType: 'application/json', }) const { proxyMarketRequest } = await import('./proxy') @@ -72,8 +72,8 @@ describe('market proxy search cache', () => { it('uses a global cache key that does not vary by caller headers', async () => { mockReadServerJsonCache.mockResolvedValue({ body: '{"data":[]}', + headers: [['content-type', 'application/json']], status: 200, - contentType: 'application/json', }) const { proxyMarketRequest } = await import('./proxy') @@ -134,9 +134,70 @@ describe('market proxy search cache', () => { expect(mockWriteServerJsonCache).toHaveBeenCalledTimes(1) expect(mockWriteServerJsonCache.mock.calls[0]?.[1]).toEqual({ body: '{"data":[]}', + headers: [['content-type', 'application/json']], status: 200, - contentType: 'application/json', }) expect(mockWriteServerJsonCache.mock.calls[0]?.[2]).toBe(300) }) + + it('stores successful get responses in the shared server JSON cache', async () => { + mockReadServerJsonCache.mockResolvedValue(null) + fetchMock.mockResolvedValue( + new Response('{"data":{"listing_id":"TG_LSTG_AAPL"}}', { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) + ) + + const { proxyMarketRequest } = await import('./proxy') + const response = await proxyMarketRequest( + new NextRequest('http://localhost/api/market/get/listing?listing_id=TG_LSTG_AAPL'), + ['get', 'listing'] + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { listing_id: 'TG_LSTG_AAPL' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://market.example.com/api/get/listing?listing_id=TG_LSTG_AAPL&version=v1', + expect.objectContaining({ + method: 'GET', + }) + ) + expect(mockWriteServerJsonCache).toHaveBeenCalledTimes(1) + }) + + it('does not read or write cache for update requests', async () => { + mockReadServerJsonCache.mockResolvedValue({ + body: '{"cached":true}', + headers: [['content-type', 'application/json']], + status: 200, + }) + fetchMock.mockResolvedValue( + new Response('{"success":true}', { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) + ) + + const { proxyMarketRequest } = await import('./proxy') + const response = await proxyMarketRequest( + new NextRequest('http://localhost/api/market/update/listing-rank?listing_id=TG_LSTG_AAPL', { + body: '{}', + method: 'POST', + }), + ['update', 'listing-rank'], + new URLSearchParams({ listing_id: 'TG_LSTG_AAPL' }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true }) + expect(mockReadServerJsonCache).not.toHaveBeenCalled() + expect(mockWriteServerJsonCache).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/tradinggoose/app/api/market/proxy.ts b/apps/tradinggoose/app/api/market/proxy.ts index f432b2cc7..03e1c5ff5 100644 --- a/apps/tradinggoose/app/api/market/proxy.ts +++ b/apps/tradinggoose/app/api/market/proxy.ts @@ -1,20 +1,10 @@ -import { createHash } from 'crypto' import { type NextRequest, NextResponse } from 'next/server' -import { readServerJsonCache, writeServerJsonCache } from '@/lib/cache/server-json-cache' import { createLogger } from '@/lib/logs/console/logger' -import { MARKET_API_URL_DEFAULT, MARKET_API_VERSION } from '@/lib/market/client/constants' -import { resolveMarketApiServiceConfig } from '@/lib/system-services/runtime' +import { MARKET_API_VERSION } from '@/lib/market/client/constants' +import { requestTradingGooseMarket } from '@/lib/market/request-gate' import { generateRequestId } from '@/lib/utils' const logger = createLogger('MarketProxyAPI') -const MARKET_SEARCH_CACHE_PREFIX = 'market:search:' -const MARKET_SEARCH_CACHE_TTL_SECONDS = 60 * 5 - -type CachedSearchResponse = { - body: string - contentType: string | null - status: number -} const hopByHopHeaders = new Set([ 'connection', @@ -49,51 +39,58 @@ const resolveVersion = (body: unknown, params?: URLSearchParams | null) => { return normalizeVersion(raw || MARKET_API_VERSION) } -const buildSearchCacheKey = (targetUrl: string) => - `${MARKET_SEARCH_CACHE_PREFIX}${createHash('sha256').update(targetUrl).digest('hex')}` - -const buildSearchCacheTarget = ( - pathSegments: string[] | undefined, - params: URLSearchParams -) => { +const buildMarketEndpoint = (pathSegments?: string[], overrideSearchParams?: URLSearchParams) => { const path = pathSegments?.length ? `/${pathSegments.join('/')}` : '' - const search = params.toString() + const search = overrideSearchParams?.toString() return `/api${path}${search ? `?${search}` : ''}` } -const buildTargetUrl = ( - marketApiUrl: string, +const buildMarketEndpointFromRequest = ( request: NextRequest, pathSegments?: string[], overrideSearchParams?: URLSearchParams ) => { const path = pathSegments?.length ? `/${pathSegments.join('/')}` : '' - const target = new URL(`/api${path}`, marketApiUrl) + const endpoint = new URL(`/api${path}`, 'https://local.tradinggoose') if (overrideSearchParams) { const search = overrideSearchParams.toString() - target.search = search ? `?${search}` : '' + endpoint.search = search ? `?${search}` : '' } else { - target.search = request.nextUrl.search ?? '' + endpoint.search = request.nextUrl.search ?? '' } - return target.toString() + return `${endpoint.pathname}${endpoint.search}` } -const buildForwardHeaders = (request: NextRequest, apiKey: string | null) => { +const buildForwardHeaders = (request: NextRequest) => { const headers = new Headers() request.headers.forEach((value, key) => { if (!hopByHopHeaders.has(key.toLowerCase())) { headers.set(key, value) } }) - if (apiKey) { - headers.set('x-api-key', apiKey) - } if (!headers.get('content-type')) { headers.set('content-type', 'application/json') } return headers } +const buildProxyResponse = async (response: Response) => { + const responseHeaders = new Headers() + response.headers.forEach((value, key) => { + if (!hopByHopHeaders.has(key.toLowerCase())) { + responseHeaders.set(key, value) + } + }) + + responseHeaders.delete('content-encoding') + responseHeaders.delete('content-length') + + return new NextResponse(await response.text(), { + status: response.status, + headers: responseHeaders, + }) +} + export const proxyMarketRequest = async ( request: NextRequest, pathSegments?: string[], @@ -138,6 +135,8 @@ export const proxyMarketRequest = async ( ) } + const headers = buildForwardHeaders(request) + if (method === 'GET') { const targetSearchParams = new URLSearchParams( (overrideSearchParams ?? request.nextUrl.searchParams).toString() @@ -145,87 +144,26 @@ export const proxyMarketRequest = async ( if (!targetSearchParams.get('version')) { targetSearchParams.set('version', version) } - if (isSearch) { - const cacheKey = buildSearchCacheKey(buildSearchCacheTarget(pathSegments, targetSearchParams)) - const cached = await readServerJsonCache<CachedSearchResponse>(cacheKey) - if (cached) { - return new NextResponse(cached.body, { - status: cached.status, - headers: cached.contentType - ? { 'content-type': cached.contentType } - : undefined, - }) + const response = await requestTradingGooseMarket( + buildMarketEndpoint(pathSegments, targetSearchParams), + { + method, + headers, } - } - const marketApi = await resolveMarketApiServiceConfig() - const targetUrl = buildTargetUrl( - marketApi.baseUrl || MARKET_API_URL_DEFAULT, - request, - pathSegments, - targetSearchParams ) - const headers = buildForwardHeaders(request, marketApi.apiKey) - const response = await fetch(targetUrl, { - method, - headers, - }) - - const responseHeaders = new Headers() - response.headers.forEach((value, key) => { - if (!hopByHopHeaders.has(key.toLowerCase())) { - responseHeaders.set(key, value) - } - }) - - responseHeaders.delete('content-encoding') - responseHeaders.delete('content-length') - - const responseBody = await response.text() - - if (isSearch && response.ok) { - await writeServerJsonCache(buildSearchCacheKey(buildSearchCacheTarget(pathSegments, targetSearchParams)), { - body: responseBody, - status: response.status, - contentType: responseHeaders.get('content-type'), - }, MARKET_SEARCH_CACHE_TTL_SECONDS) - } - - return new NextResponse(responseBody, { - status: response.status, - headers: responseHeaders, - }) + return buildProxyResponse(response) } - const marketApi = await resolveMarketApiServiceConfig() - const targetUrl = buildTargetUrl( - marketApi.baseUrl || MARKET_API_URL_DEFAULT, - request, - pathSegments, - overrideSearchParams - ) - const headers = buildForwardHeaders(request, marketApi.apiKey) const forwardBody = JSON.stringify({ ...bodyPayload, version }) - const response = await fetch(targetUrl, { - method, - headers, - body: forwardBody, - }) - - const responseHeaders = new Headers() - response.headers.forEach((value, key) => { - if (!hopByHopHeaders.has(key.toLowerCase())) { - responseHeaders.set(key, value) + const response = await requestTradingGooseMarket( + buildMarketEndpointFromRequest(request, pathSegments, overrideSearchParams), + { + method, + headers, + body: forwardBody, } - }) - - // Avoid content decoding mismatches when proxying compressed responses. - responseHeaders.delete('content-encoding') - responseHeaders.delete('content-length') - - return new NextResponse(response.body, { - status: response.status, - headers: responseHeaders, - }) + ) + return buildProxyResponse(response) } catch (error) { logger.error(`[${requestId}] Market proxy failed`, { error: error instanceof Error ? error.message : String(error), diff --git a/apps/tradinggoose/app/api/market/search/validation.ts b/apps/tradinggoose/app/api/market/search/validation.ts index 27fabb76c..fb0201a08 100644 --- a/apps/tradinggoose/app/api/market/search/validation.ts +++ b/apps/tradinggoose/app/api/market/search/validation.ts @@ -20,7 +20,7 @@ export const buildQueryParams = (request: NextRequest, keys: string[]) => { return params } -export const uniqueNonEmpty = (values: Array<string | null | undefined>) => { +export const dedupeNonEmptyStrings = (values: Array<string | null | undefined>) => { const seen = new Set<string>() const result: string[] = [] for (const value of values) { @@ -32,10 +32,7 @@ export const uniqueNonEmpty = (values: Array<string | null | undefined>) => { } export const parseListParam = (searchParams: URLSearchParams, key: string) => { - const rawValues = [ - ...searchParams.getAll(key), - ...searchParams.getAll(`${key}[]`), - ] + const rawValues = [...searchParams.getAll(key), ...searchParams.getAll(`${key}[]`)] if (!rawValues.length) return [] const tokens: string[] = [] @@ -77,5 +74,5 @@ export const parseListParam = (searchParams: URLSearchParams, key: string) => { pushToken(trimmed) } - return uniqueNonEmpty(tokens) + return dedupeNonEmptyStrings(tokens) } diff --git a/apps/tradinggoose/app/api/providers/trading/order/route.test.ts b/apps/tradinggoose/app/api/providers/trading/order/route.test.ts new file mode 100644 index 000000000..ec1240ddb --- /dev/null +++ b/apps/tradinggoose/app/api/providers/trading/order/route.test.ts @@ -0,0 +1,581 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@/app/api/__test-utils__/utils' + +const mockGetSession = vi.fn() +const mockGetOAuthToken = vi.fn() +const mockListTradingAccounts = vi.fn() +const mockFetch = vi.fn() + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/app/api/auth/oauth/utils', () => ({ + getOAuthToken: mockGetOAuthToken, +})) + +vi.mock('@/providers/trading/portfolio', async () => { + const actual = await vi.importActual('@/providers/trading/portfolio') + return { + ...(actual as object), + listTradingAccounts: mockListTradingAccounts, + } +}) + +const stockListing = { + listing_type: 'default', + listing_id: 'AAPL', + base: 'AAPL', + quote: 'USD', + assetClass: 'stock', +} + +const etfListing = { + listing_type: 'default', + listing_id: 'SPY', + base: 'SPY', + quote: 'USD', + assetClass: 'etf', +} + +describe('Trading provider order route', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetOAuthToken.mockResolvedValue('access-token') + mockListTradingAccounts.mockResolvedValue([ + { id: 'ACC-1', name: 'Main', type: 'cash', baseCurrency: 'USD', status: 'active' }, + ]) + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + order: { + id: 'order-1', + status: 'submitted', + symbol: 'AAPL', + side: 'buy', + create_date: '2026-04-25T12:00:00.000Z', + message: 'Order accepted', + }, + }), + { status: 200 } + ) + ) + }) + + it('rejects invalid JSON before auth or broker calls', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + new Request('http://localhost:3000/api/providers/trading/order', { + method: 'POST', + body: '{', + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects invalid sides and numeric strings before auth or broker calls', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const invalidSideResponse = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'hold', + quantity: 1, + }) + ) + const numericStringResponse = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: '1', + }) + ) + + expect(invalidSideResponse.status).toBe(400) + await expect(invalidSideResponse.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(numericStringResponse.status).toBe(400) + await expect(numericStringResponse.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects listings without resolved asset class before account discovery', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: { listing_type: 'default', listing_id: 'AAPL', base: 'AAPL' }, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: 'Resolved listing asset class is required', + }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects raw string listings before auth or broker calls', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: 'AAPL', + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each(['providerParams', 'class', 'tag', 'preview', 'optionSymbol', 'option_symbol', 'legs'])( + 'rejects unsupported top-level quick order extras: %s', + async (field) => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + [field]: field === 'legs' ? [] : 'advanced', + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + } + ) + + it('rejects unsupported listing asset classes before account discovery', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'ACC-1', + listing: etfListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Unsupported listing for provider' }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects unsupported order types before account discovery', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + orderType: 'trailing_stop', + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Unsupported order type' }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects Alpaca notional trailing stop orders before account discovery', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + orderSizingMode: 'notional', + notional: 100, + orderType: 'trailing_stop', + timeInForce: 'day', + trailPrice: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: 'Alpaca notional orders support market, limit, stop, or stop_limit types.', + }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects no supported order types before account discovery', async () => { + vi.resetModules() + vi.doMock('@/providers/trading/order-types', async () => { + const actual = await vi.importActual<typeof import('@/providers/trading/order-types')>( + '@/providers/trading/order-types' + ) + return { + ...actual, + getStrictTradingOrderTypeDefinitions: vi.fn(() => []), + } + }) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: 'No supported order types for listing', + }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + + vi.doUnmock('@/providers/trading/order-types') + vi.resetModules() + }) + + it('rejects missing provider connections before account discovery', async () => { + mockGetOAuthToken.mockResolvedValue(null) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ + error: 'Trading provider connection not found', + }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('requires accountId before account discovery', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects accounts that do not belong to the provider connection', async () => { + mockListTradingAccounts.mockResolvedValue([{ id: 'ACC-2', type: 'cash', baseCurrency: 'USD' }]) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ + error: 'Account not found for provider connection', + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each([ + [ + { + trailPrice: 1, + trailPercent: 1, + }, + 'Enter either trail price or trail percent.', + ], + [{}, 'Enter either trail price or trail percent.'], + [ + { + trailPrice: 1, + limitPrice: 100, + }, + 'Alpaca trailing stop orders do not accept limitPrice or stopPrice', + ], + ])( + 'rejects invalid Alpaca trailing stop payloads before account discovery', + async (fields, error) => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'ACC-1', + listing: stockListing, + side: 'sell', + quantity: 1, + orderType: 'trailing_stop', + ...fields, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + } + ) + + it('submits valid Alpaca quantity orders without using order history', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'alpaca-order-1', + status: 'accepted', + symbol: 'AAPL', + side: 'buy', + submitted_at: '2026-04-25T12:00:00.000Z', + }), + { status: 200 } + ) + ) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 3, + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + provider: 'alpaca', + accountId: 'ACC-1', + order: { + id: 'alpaca-order-1', + status: 'accepted', + symbol: 'AAPL', + side: 'buy', + }, + }) + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.alpaca.markets/v2/orders') + expect(url).not.toContain('/api/tools/trading/order-history') + expect(JSON.parse(String(init.body))).toMatchObject({ + symbol: 'AAPL', + side: 'buy', + type: 'market', + time_in_force: 'day', + qty: '3', + }) + }) + + it('submits valid Alpaca notional orders without sending quantity', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'alpaca-order-2', status: 'accepted' }), { + status: 200, + }) + ) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + orderSizingMode: 'notional', + notional: 100.5, + timeInForce: 'day', + }) + ) + + expect(response.status).toBe(200) + expect(mockFetch).toHaveBeenCalledTimes(1) + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(String(init.body))).toMatchObject({ + symbol: 'AAPL', + side: 'buy', + type: 'market', + time_in_force: 'day', + notional: 100.5, + }) + expect(JSON.parse(String(init.body))).not.toHaveProperty('qty') + }) + + it('submits valid Tradier equity quantity orders without using order history', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 3, + limitPrice: 100, + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + provider: 'tradier', + accountId: 'ACC-1', + message: 'Order accepted', + order: { + id: 'order-1', + status: 'submitted', + symbol: 'AAPL', + side: 'buy', + }, + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toContain('/accounts/ACC-1/orders') + expect(url).not.toContain('/api/tools/trading/order-history') + expect(String(init.body)).toContain('class=equity') + expect(String(init.body)).toContain('symbol=AAPL') + expect(String(init.body)).toContain('quantity=3') + expect(String(init.body)).not.toContain('price=') + }) + + it('preserves listing enrichment fields in provider builders', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: { + ...stockListing, + listing_id: 'IGNORED', + base: 'TSLA', + marketCode: 'NASDAQ', + countryCode: 'US', + cityName: 'New York', + }, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(200) + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(String(init.body)).toContain('symbol=TSLA') + expect(String(init.body)).not.toContain('IGNORED') + }) + + it('extracts broker message-like fields into the quick order response', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + order: { + id: 'order-2', + status: 'rejected', + symbol: 'AAPL', + reject_reason: 'Insufficient buying power', + }, + }), + { status: 200 } + ) + ) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + message: 'Insufficient buying power', + }) + }) + + it('maps broker fetch failures to 502 without persisting', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'Broker unavailable' }), { status: 500 }) + ) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(502) + await expect(response.json()).resolves.toEqual({ error: 'Broker request failed' }) + }) +}) diff --git a/apps/tradinggoose/app/api/providers/trading/order/route.ts b/apps/tradinggoose/app/api/providers/trading/order/route.ts new file mode 100644 index 000000000..c3b56abfd --- /dev/null +++ b/apps/tradinggoose/app/api/providers/trading/order/route.ts @@ -0,0 +1,383 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import type { ListingInputValue } from '@/lib/listing/identity' +import { toListingValueObject } from '@/lib/listing/identity' +import type { QuickOrderSubmitResponse } from '@/app/api/providers/trading/order/types' +import { + createTradingProviderRequestId, + logBrokerRequestFailure, + resolveTradingProviderContext, + resolveTradingProviderPreflight, + resolveTradingProviderSelectedAccount, +} from '@/app/api/providers/trading/shared' +import { executeTradingProviderRequest, getTradingProvider } from '@/providers/trading' +import { getStrictTradingOrderTypeDefinitions } from '@/providers/trading/order-types' +import { + ALPACA_TRAILING_STOP_TRAIL_VALUE_ERROR, + getAlpacaNotionalOrderTypeError, +} from '@/providers/trading/order-validation' +import { fetchBrokerJson, TradingBrokerRequestError } from '@/providers/trading/portfolio-utils' +import { getTradingProviderConfig } from '@/providers/trading/providers' +import type { TradingOrder, TradingOrderRequest, TradingOrderType } from '@/providers/trading/types' +import { + isTradingOrderListingSupported, + resolveTradingListingAssetClass, +} from '@/providers/trading/utils' + +const positiveNumberSchema = z.number().positive().finite() +const nonEmptyStringSchema = z.string().trim().min(1) + +const orderListingSchema = z + .object({ + listing_type: z.enum(['default', 'crypto', 'currency']), + listing_id: z.string().optional(), + base_id: z.string().optional(), + quote_id: z.string().optional(), + }) + .passthrough() + +const orderSchema = z + .object({ + provider: nonEmptyStringSchema, + credentialServiceId: nonEmptyStringSchema.optional(), + accountId: nonEmptyStringSchema, + listing: orderListingSchema, + side: z.enum(['buy', 'sell']), + quantity: positiveNumberSchema.optional(), + notional: positiveNumberSchema.optional(), + orderSizingMode: z.enum(['quantity', 'notional']).optional(), + orderType: nonEmptyStringSchema.optional(), + timeInForce: nonEmptyStringSchema.optional(), + limitPrice: positiveNumberSchema.optional(), + stopPrice: positiveNumberSchema.optional(), + trailPrice: positiveNumberSchema.optional(), + trailPercent: positiveNumberSchema.optional(), + }) + .strict() + +type OrderRequestData = z.infer<typeof orderSchema> + +const errorResponse = (error: string, status = 400) => NextResponse.json({ error }, { status }) + +const hasNumber = (value: number | undefined): value is number => + typeof value === 'number' && Number.isFinite(value) + +const getTimeInForceOptions = (providerId: string) => + getTradingProviderConfig(providerId)?.capabilities?.order?.timeInForce ?? [] + +const resolveTimeInForce = ( + providerId: string, + requested: string | undefined +): string | NextResponse => { + const timeInForceOptions = getTimeInForceOptions(providerId) + const requestedTimeInForce = requested?.trim() + + if (requestedTimeInForce) { + if (!timeInForceOptions.includes(requestedTimeInForce)) { + return errorResponse('Unsupported timeInForce for provider') + } + return requestedTimeInForce + } + + const provider = getTradingProvider(providerId) + const fallback = provider.defaults?.timeInForce ?? timeInForceOptions[0] + return fallback || errorResponse('timeInForce is required') +} + +const resolveOrderType = ( + providerId: string, + data: OrderRequestData +): { orderType: TradingOrderType; requires: string[] } | NextResponse => { + const context = { + listing: data.listing as ListingInputValue, + orderClass: providerId === 'tradier' ? 'equity' : undefined, + } + const strictDefinitions = getStrictTradingOrderTypeDefinitions(providerId, context) + if (!strictDefinitions.length) { + return errorResponse('No supported order types for listing') + } + + const requestedOrderType = data.orderType?.trim() + if (requestedOrderType) { + const requestedDefinition = strictDefinitions.find( + (definition) => definition.id === requestedOrderType + ) + if (!requestedDefinition) { + return errorResponse('Unsupported order type') + } + return { + orderType: requestedDefinition.id as TradingOrderType, + requires: requestedDefinition.requires ?? [], + } + } + + const provider = getTradingProvider(providerId) + const defaultDefinition = + strictDefinitions.find((definition) => definition.id === provider.defaults?.orderType) ?? + strictDefinitions[0] + + return { + orderType: defaultDefinition.id as TradingOrderType, + requires: defaultDefinition.requires ?? [], + } +} + +const validateRequiredNumber = ( + data: OrderRequestData, + field: 'limitPrice' | 'stopPrice' | 'trailPrice' | 'trailPercent' +): NextResponse | null => { + return hasNumber(data[field]) ? null : errorResponse(`${field} is required`) +} + +const validateAlpacaSizing = ( + data: OrderRequestData, + orderType: TradingOrderType, + timeInForce: string +): NextResponse | null => { + const sizingMode = data.orderSizingMode ?? 'quantity' + if (sizingMode === 'notional') { + if (!hasNumber(data.notional)) return errorResponse('notional is required') + const orderTypeError = getAlpacaNotionalOrderTypeError(orderType) + if (orderTypeError) return errorResponse(orderTypeError) + if (timeInForce !== 'day') { + return errorResponse('Alpaca notional orders require timeInForce=day') + } + return null + } + + return hasNumber(data.quantity) ? null : errorResponse('quantity is required') +} + +const validateTradierSizing = (data: OrderRequestData): NextResponse | null => { + if (data.orderSizingMode || hasNumber(data.notional)) { + return errorResponse('Notional sizing is only supported for Alpaca') + } + return hasNumber(data.quantity) ? null : errorResponse('quantity is required') +} + +const validateOrderFields = ( + providerId: string, + data: OrderRequestData, + orderType: TradingOrderType, + requires: string[], + timeInForce: string +): NextResponse | null => { + const sizingError = + providerId === 'alpaca' + ? validateAlpacaSizing(data, orderType, timeInForce) + : validateTradierSizing(data) + if (sizingError) return sizingError + + if (providerId === 'alpaca' && orderType === 'trailing_stop') { + const hasTrailPrice = hasNumber(data.trailPrice) + const hasTrailPercent = hasNumber(data.trailPercent) + if (hasNumber(data.limitPrice) || hasNumber(data.stopPrice)) { + return errorResponse('Alpaca trailing stop orders do not accept limitPrice or stopPrice') + } + if (hasTrailPrice === hasTrailPercent) { + return errorResponse(ALPACA_TRAILING_STOP_TRAIL_VALUE_ERROR) + } + return null + } + + for (const field of requires) { + if ( + field === 'limitPrice' || + field === 'stopPrice' || + field === 'trailPrice' || + field === 'trailPercent' + ) { + const fieldError = validateRequiredNumber(data, field) + if (fieldError) return fieldError + } + } + + return null +} + +const buildOrderRequest = ( + providerId: string, + data: OrderRequestData, + context: { + accessToken: string + accountId: string + environment: 'paper' | 'live' + }, + orderType: TradingOrderType, + timeInForce: string +): TradingOrderRequest => { + const usesLimitPrice = orderType === 'limit' || orderType === 'stop_limit' + const usesStopPrice = orderType === 'stop' || orderType === 'stop_limit' + const usesTrailValue = orderType === 'trailing_stop' + const request: TradingOrderRequest = { + kind: 'order', + listing: data.listing as ListingInputValue, + assetClass: resolveTradingListingAssetClass(data.listing as ListingInputValue), + side: data.side, + orderType, + timeInForce, + quantity: data.quantity, + limitPrice: usesLimitPrice ? data.limitPrice : undefined, + stopPrice: usesStopPrice ? data.stopPrice : undefined, + trailPrice: usesTrailValue ? data.trailPrice : undefined, + trailPercent: usesTrailValue ? data.trailPercent : undefined, + environment: context.environment, + accessToken: context.accessToken, + accountId: context.accountId, + } + + if (providerId === 'alpaca') { + request.orderSizingMode = data.orderSizingMode ?? 'quantity' + if (request.orderSizingMode === 'notional') { + request.quantity = undefined + request.notional = data.notional + } + } + + if (providerId === 'tradier') { + request.providerParams = { orderClass: 'equity' } + } + + return request +} + +const toFetchBody = (body: string | Record<string, any> | undefined) => { + if (typeof body === 'string' || body === undefined) return body + return JSON.stringify(body) +} + +const MESSAGE_KEYS = ['message', 'status_message', 'reason', 'reject_reason', 'error'] as const + +const readMessage = (value: unknown, seen = new WeakSet<object>()): string | null => { + if (typeof value === 'string' && value.trim()) return value.trim() + if (!value || typeof value !== 'object') return null + if (seen.has(value)) return null + seen.add(value) + + const record = value as Record<string, unknown> + + for (const key of MESSAGE_KEYS) { + const message = record[key] + if (typeof message === 'string' && message.trim()) return message.trim() + } + + const errors = record.errors + if (Array.isArray(errors)) { + for (const error of errors) { + const nested = readMessage(error, seen) + if (nested) return nested + } + } else { + const nested = readMessage(errors, seen) + if (nested) return nested + } + + for (const key of ['order', 'raw'] as const) { + const nested = readMessage(record[key], seen) + if (nested) return nested + } + + return null +} + +const extractOrderProviderMessage = ( + rawOrder: unknown, + normalizedOrder: TradingOrder | null +): string | null => + readMessage(rawOrder) ?? readMessage(normalizedOrder?.raw) ?? readMessage(normalizedOrder) + +export async function POST(request: Request) { + const requestId = createTradingProviderRequestId('order') + const requestData = await resolveTradingProviderPreflight({ + request, + schema: orderSchema, + }) + if (requestData instanceof Response) return requestData + + const baseContext = await resolveTradingProviderContext({ + requestData, + requestId, + }) + if (baseContext instanceof Response) return baseContext + + const resolvedListingForRequest = requestData.listing as ListingInputValue + const listingIdentity = toListingValueObject(resolvedListingForRequest) + if (!listingIdentity) { + return errorResponse('Resolved listing is required') + } + + const assetClass = resolveTradingListingAssetClass(resolvedListingForRequest) + if (!assetClass) { + return errorResponse('Resolved listing asset class is required') + } + + if (!isTradingOrderListingSupported(baseContext.providerId, resolvedListingForRequest)) { + return errorResponse('Unsupported listing for provider') + } + + const orderTypeResult = resolveOrderType(baseContext.providerId, requestData) + if (orderTypeResult instanceof Response) return orderTypeResult + + const timeInForce = resolveTimeInForce(baseContext.providerId, requestData.timeInForce) + if (timeInForce instanceof Response) return timeInForce + + const fieldError = validateOrderFields( + baseContext.providerId, + requestData, + orderTypeResult.orderType, + orderTypeResult.requires, + timeInForce + ) + if (fieldError) return fieldError + + const accountContext = await resolveTradingProviderSelectedAccount({ + baseContext, + accountId: requestData.accountId, + }) + if (accountContext instanceof Response) return accountContext + + try { + const provider = getTradingProvider(baseContext.providerId) + const providerRequest = executeTradingProviderRequest( + baseContext.providerId, + buildOrderRequest( + baseContext.providerId, + requestData, + { + accessToken: baseContext.accessToken, + accountId: accountContext.accountId, + environment: baseContext.environment, + }, + orderTypeResult.orderType, + timeInForce + ) + ) + + const rawOrder = await fetchBrokerJson<unknown>({ + providerId: baseContext.providerId, + url: providerRequest.url, + init: { + method: providerRequest.method, + headers: providerRequest.headers, + body: toFetchBody(providerRequest.body), + }, + }) + + const order = provider.normalizeOrder?.(rawOrder) ?? null + + const response: QuickOrderSubmitResponse = { + order, + provider: baseContext.providerId, + accountId: accountContext.accountId, + message: extractOrderProviderMessage(rawOrder, order), + } + + return NextResponse.json(response) + } catch (error) { + logBrokerRequestFailure('order', error) + if (error instanceof TradingBrokerRequestError) { + return errorResponse('Broker request failed', 502) + } + return errorResponse(error instanceof Error ? error.message : 'Order submission failed') + } +} diff --git a/apps/tradinggoose/app/api/providers/trading/order/types.ts b/apps/tradinggoose/app/api/providers/trading/order/types.ts new file mode 100644 index 000000000..d089a0093 --- /dev/null +++ b/apps/tradinggoose/app/api/providers/trading/order/types.ts @@ -0,0 +1,30 @@ +import type { ListingIdentity, ListingResolved } from '@/lib/listing/identity' +import type { TradingOrder } from '@/providers/trading/types' + +export type QuickOrderResolvedListing = + | ListingResolved + | (ListingIdentity & Record<string, unknown>) + +export interface QuickOrderSubmitRequest { + provider: string + credentialServiceId?: string + accountId: string + listing: QuickOrderResolvedListing + side: 'buy' | 'sell' + quantity?: number + notional?: number + orderSizingMode?: 'quantity' | 'notional' + orderType?: string + timeInForce?: string + limitPrice?: number + stopPrice?: number + trailPrice?: number + trailPercent?: number +} + +export interface QuickOrderSubmitResponse { + order: TradingOrder | null + provider: string + accountId: string + message?: string | null +} diff --git a/apps/tradinggoose/app/api/providers/trading/shared.ts b/apps/tradinggoose/app/api/providers/trading/shared.ts new file mode 100644 index 000000000..e44d2e17a --- /dev/null +++ b/apps/tradinggoose/app/api/providers/trading/shared.ts @@ -0,0 +1,175 @@ +import { NextResponse } from 'next/server' +import type { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { getOAuthToken } from '@/app/api/auth/oauth/utils' +import { listTradingAccounts } from '@/providers/trading/portfolio' +import { TradingBrokerRequestError } from '@/providers/trading/portfolio-utils' +import { + getTradingProviderDefinition, + getTradingProviderOAuthEnvironment, + getTradingProviderOAuthServiceId, +} from '@/providers/trading/providers' +import type { UnifiedTradingAccount } from '@/providers/trading/types' + +const logger = createLogger('TradingProviderRoutes') + +type ProviderRequestData = { + provider?: string + credentialServiceId?: string +} + +type PreflightContext = { + requestId: string + providerId: string + environment: 'paper' | 'live' + accessToken: string + sessionUserId: string +} + +export type TradingProviderBaseRouteContext = PreflightContext + +export type TradingAccountRouteContext = PreflightContext & { + accountId: string + account: UnifiedTradingAccount +} + +const parseRequestBody = async <T extends ProviderRequestData>( + request: Request, + schema: z.ZodSchema<T> +): Promise<T | NextResponse> => { + let body: unknown + + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid request data' }, { status: 400 }) + } + + const parsed = schema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request data' }, { status: 400 }) + } + + return parsed.data +} + +const requireStringField = ( + data: Record<string, string | undefined>, + field: string +): string | NextResponse => { + const value = data[field]?.trim() + if (!value) { + return NextResponse.json({ error: `${field} is required` }, { status: 400 }) + } + return value +} + +export async function resolveTradingProviderPreflight<T extends ProviderRequestData>({ + request, + schema, +}: { + request: Request + schema: z.ZodSchema<T> +}): Promise<T | NextResponse> { + return parseRequestBody(request, schema) +} + +export async function resolveTradingProviderContext({ + requestData, + requestId, +}: { + requestData: ProviderRequestData + requestId: string +}): Promise<PreflightContext | NextResponse> { + const providerId = requireStringField(requestData, 'provider') + if (providerId instanceof NextResponse) return providerId + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const providerDefinition = getTradingProviderDefinition(providerId) + if (!providerDefinition) { + return NextResponse.json({ error: 'Unsupported provider' }, { status: 400 }) + } + + const serviceId = getTradingProviderOAuthServiceId(providerId, requestData.credentialServiceId) + if (!serviceId) { + return NextResponse.json({ error: 'Trading provider connection is required' }, { status: 400 }) + } + + const accessToken = await getOAuthToken(session.user.id, serviceId) + if (!accessToken) { + return NextResponse.json({ error: 'Trading provider connection not found' }, { status: 404 }) + } + const environment = getTradingProviderOAuthEnvironment(providerId, serviceId) + if (!environment) { + return NextResponse.json( + { error: 'Trading provider connection is not configured' }, + { status: 400 } + ) + } + + return { + requestId, + providerId, + environment, + accessToken, + sessionUserId: session.user.id, + } +} + +export async function resolveTradingProviderSelectedAccount({ + baseContext, + accountId, +}: { + baseContext: TradingProviderBaseRouteContext + accountId?: string +}): Promise<TradingAccountRouteContext | NextResponse> { + const selectedAccountId = requireStringField({ accountId }, 'accountId') + if (selectedAccountId instanceof NextResponse) return selectedAccountId + + const accounts = await listTradingAccounts({ + providerId: baseContext.providerId, + environment: baseContext.environment, + accessToken: baseContext.accessToken, + }) + + const account = accounts.find((candidate) => candidate.id === selectedAccountId) + if (!account) { + return NextResponse.json( + { error: 'Account not found for provider connection' }, + { status: 404 } + ) + } + + return { + ...baseContext, + accountId: selectedAccountId, + account, + } +} + +export const createTradingProviderRequestId = (route: string) => + `${route}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + +export const logBrokerRequestFailure = (route: string, error: unknown) => { + if (error instanceof TradingBrokerRequestError) { + logger.error(`Broker request failed in ${route}`, { + error: error.message, + stack: error.stack, + providerId: error.providerId, + status: error.status, + url: error.url, + payload: error.payload, + }) + return + } + + logger.error(`Broker request failed in ${route}`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) +} diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index e8135fb8c..9ee6d745a 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -4,13 +4,7 @@ import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { - loadWorkflowFromNormalizedTables, - type NormalizedWorkflowData, -} from '@/lib/workflows/db-helpers' -import { - resolveAutoLayoutDirection, -} from '@/lib/workflows/workflow-direction' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { getWorkflowAccessContext } from '@/lib/workflows/utils' export const dynamic = 'force-dynamic' @@ -18,46 +12,29 @@ export const dynamic = 'force-dynamic' const logger = createLogger('AutoLayoutAPI') const AutoLayoutRequestSchema = z.object({ - strategy: z - .enum(['smart', 'hierarchical', 'layered', 'force-directed']) - .optional() - .default('smart'), - direction: z.enum(['horizontal', 'vertical', 'auto']).optional().default('auto'), spacing: z .object({ - horizontal: z.number().min(100).max(1000).optional().default(400), - vertical: z.number().min(50).max(500).optional().default(200), - layer: z.number().min(200).max(1200).optional().default(600), + horizontal: z.number().min(100).max(1000).optional(), + vertical: z.number().min(50).max(500).optional(), }) - .optional() - .default({}), - alignment: z.enum(['start', 'center', 'end']).optional().default('center'), + .optional(), + alignment: z.enum(['start', 'center', 'end']).optional(), padding: z .object({ - x: z.number().min(50).max(500).optional().default(200), - y: z.number().min(50).max(500).optional().default(200), + x: z.number().min(50).max(500).optional(), + y: z.number().min(50).max(500).optional(), }) - .optional() - .default({}), - // Optional: if provided, use these blocks instead of loading from DB - // This allows using blocks with live measurements from the UI + .optional(), blocks: z.record(z.any()).optional(), edges: z.array(z.any()).optional(), - loops: z.record(z.any()).optional(), - parallels: z.record(z.any()).optional(), }) -/** - * POST /api/workflows/[id]/autolayout - * Apply autolayout to an existing workflow - */ export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() const startTime = Date.now() const { id: workflowId } = await params try { - // Get the session const session = await getSession() if (!session?.user?.id) { logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`) @@ -66,17 +43,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const userId = session.user.id - // Parse request body const body = await request.json() const layoutOptions = AutoLayoutRequestSchema.parse(body) logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, { - strategy: layoutOptions.strategy, - direction: layoutOptions.direction, userId, }) - // Fetch the workflow to check ownership/access const accessContext = await getWorkflowAccessContext(workflowId, userId) const workflowData = accessContext?.workflow @@ -85,7 +58,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - // Check if user has permission to update this workflow const canUpdate = accessContext?.isOwner || (workflowData.workspaceId @@ -100,18 +72,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // Use provided blocks/edges if available (with live measurements from UI), - // otherwise load from database - let currentWorkflowData: NormalizedWorkflowData | null + let currentWorkflowData: { blocks: Record<string, any>; edges: any[] } | null if (layoutOptions.blocks && layoutOptions.edges) { logger.info(`[${requestId}] Using provided blocks with live measurements`) currentWorkflowData = { blocks: layoutOptions.blocks, edges: layoutOptions.edges, - loops: layoutOptions.loops || {}, - parallels: layoutOptions.parallels || {}, - isFromNormalizedTables: false, } } else { logger.info(`[${requestId}] Loading blocks from database`) @@ -124,27 +91,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } const autoLayoutOptions = { - direction: resolveAutoLayoutDirection( - { - blocks: currentWorkflowData.blocks, - edges: currentWorkflowData.edges, - }, - layoutOptions.direction - ), - horizontalSpacing: layoutOptions.spacing?.horizontal || 550, - verticalSpacing: layoutOptions.spacing?.vertical || 200, + horizontalSpacing: layoutOptions.spacing?.horizontal ?? 550, + verticalSpacing: layoutOptions.spacing?.vertical ?? 200, padding: { - x: layoutOptions.padding?.x || 150, - y: layoutOptions.padding?.y || 150, + x: layoutOptions.padding?.x ?? 150, + y: layoutOptions.padding?.y ?? 150, }, - alignment: layoutOptions.alignment, + alignment: layoutOptions.alignment ?? 'center', } const layoutResult = applyAutoLayout( currentWorkflowData.blocks, currentWorkflowData.edges, - currentWorkflowData.loops || {}, - currentWorkflowData.parallels || {}, autoLayoutOptions ) @@ -166,7 +124,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Autolayout completed successfully in ${elapsed}ms`, { blockCount, - strategy: layoutOptions.strategy, workflowId, }) @@ -174,8 +131,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ success: true, message: `Autolayout applied successfully to ${blockCount} blocks`, data: { - strategy: layoutOptions.strategy, - direction: autoLayoutOptions.direction, blockCount, elapsed: `${elapsed}ms`, layoutedBlocks: layoutResult.blocks, diff --git a/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.test.ts b/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.test.ts index d5492b388..50f3c250c 100644 --- a/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.test.ts @@ -174,6 +174,29 @@ describe('Workspace Invitation [invitationId] API Route', () => { ) }) + it('should redirect to a localized login page when unauthenticated with token and locale header', async () => { + const { GET } = await import('./route') + + mockGetSession.mockResolvedValue(null) + + const request = new NextRequest( + 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123', + { + headers: { + 'x-next-intl-locale': 'zh-CN', + }, + } + ) + const params = Promise.resolve({ invitationId: 'token-abc123' }) + + const response = await GET(request, { params }) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://test.tradinggoose.ai/zh/invite/token-abc123?token=token-abc123' + ) + }) + it('should accept invitation when called with valid token', async () => { const { GET } = await import('./route') @@ -209,6 +232,46 @@ describe('Workspace Invitation [invitationId] API Route', () => { ) }) + it('should redirect accepted invitations to a localized workspace dashboard', async () => { + const { GET } = await import('./route') + + mockGetSession.mockResolvedValue({ + user: { ...mockUser, email: 'invited@example.com' }, + }) + + mockDbResults.push([mockInvitation]) + mockDbResults.push([mockWorkspace]) + mockDbResults.push([{ ...mockUser, email: 'invited@example.com' }]) + mockDbResults.push([]) + + mockTransaction.mockImplementation(async (callback: any) => { + await callback({ + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(undefined), + }) + }) + + const request = new NextRequest( + 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123', + { + headers: { + 'x-next-intl-locale': 'zh-CN', + }, + } + ) + const params = Promise.resolve({ invitationId: 'token-abc123' }) + + const response = await GET(request, { params }) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://test.tradinggoose.ai/zh/workspace/workspace-456/dashboard' + ) + }) + it('should redirect to error page when invitation expired', async () => { const { GET } = await import('./route') diff --git a/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts index b75807001..61f2421dc 100644 --- a/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts +++ b/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts @@ -12,6 +12,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' import { getBaseUrl } from '@/lib/urls/utils' +import { defaultLocale, isLocaleCode, type LocaleCode, localizeHref } from '@/i18n/utils' // GET /api/workspaces/invitations/[invitationId] - Get invitation details OR accept via token export async function GET( @@ -19,6 +20,8 @@ export async function GET( { params }: { params: Promise<{ invitationId: string }> } ) { const { invitationId } = await params + const localeHeader = req.headers.get('x-next-intl-locale') ?? '' + const locale: LocaleCode = isLocaleCode(localeHeader) ? localeHeader : defaultLocale const session = await getSession() const token = req.nextUrl.searchParams.get('token') const isAcceptFlow = !!token // If token is provided, this is an acceptance flow @@ -26,7 +29,9 @@ export async function GET( if (!session?.user?.id) { // For token-based acceptance flows, redirect to login if (isAcceptFlow) { - return NextResponse.redirect(new URL(`/invite/${invitationId}?token=${token}`, getBaseUrl())) + return NextResponse.redirect( + new URL(localizeHref(locale, `/invite/${invitationId}?token=${token}`), getBaseUrl()) + ) } return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -45,7 +50,7 @@ export async function GET( if (!invitation) { if (isAcceptFlow) { return NextResponse.redirect( - new URL(`/invite/${invitationId}?error=invalid-token`, getBaseUrl()) + new URL(localizeHref(locale, `/invite/${invitationId}?error=invalid-token`), getBaseUrl()) ) } return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 }) @@ -54,7 +59,7 @@ export async function GET( if (new Date() > new Date(invitation.expiresAt)) { if (isAcceptFlow) { return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=expired`, getBaseUrl()) + new URL(localizeHref(locale, `/invite/${invitation.id}?error=expired`), getBaseUrl()) ) } return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 }) @@ -69,7 +74,10 @@ export async function GET( if (!workspaceDetails) { if (isAcceptFlow) { return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=workspace-not-found`, getBaseUrl()) + new URL( + localizeHref(locale, `/invite/${invitation.id}?error=workspace-not-found`), + getBaseUrl() + ) ) } return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) @@ -78,7 +86,10 @@ export async function GET( if (isAcceptFlow) { if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=already-processed`, getBaseUrl()) + new URL( + localizeHref(locale, `/invite/${invitation.id}?error=already-processed`), + getBaseUrl() + ) ) } @@ -93,7 +104,10 @@ export async function GET( if (!userData) { return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=user-not-found`, getBaseUrl()) + new URL( + localizeHref(locale, `/invite/${invitation.id}?error=user-not-found`), + getBaseUrl() + ) ) } @@ -101,7 +115,10 @@ export async function GET( if (!isValidMatch) { return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=email-mismatch`, getBaseUrl()) + new URL( + localizeHref(locale, `/invite/${invitation.id}?error=email-mismatch`), + getBaseUrl() + ) ) } @@ -127,7 +144,10 @@ export async function GET( .where(eq(workspaceInvitation.id, invitation.id)) return NextResponse.redirect( - new URL(`/workspace/${invitation.workspaceId}/dashboard`, getBaseUrl()) + new URL( + localizeHref(locale, `/workspace/${invitation.workspaceId}/dashboard`), + getBaseUrl() + ) ) } @@ -152,7 +172,10 @@ export async function GET( }) return NextResponse.redirect( - new URL(`/workspace/${invitation.workspaceId}/dashboard`, getBaseUrl()) + new URL( + localizeHref(locale, `/workspace/${invitation.workspaceId}/dashboard`), + getBaseUrl() + ) ) } diff --git a/apps/tradinggoose/app/api/workspaces/route.test.ts b/apps/tradinggoose/app/api/workspaces/route.test.ts index 30113d01a..c11eaa949 100644 --- a/apps/tradinggoose/app/api/workspaces/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/route.test.ts @@ -3,12 +3,29 @@ */ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' describe('Workspaces API Route', () => { const transactionMock = vi.fn() const updateWhereMock = vi.fn() const updateSetMock = vi.fn() const updateMock = vi.fn() + let sessionUserName: string | null = 'Bruz Gomez' + const userWorkspacesQuery = { + from: vi.fn(() => ({ + innerJoin: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => userWorkspaces), + })), + })), + })), + } + const orphanedWorkflowsQuery = { + from: vi.fn(() => ({ + where: vi.fn(() => []), + })), + } + const insertedValues: Array<Record<string, unknown>> = [] let userWorkspaces: Array<{ workspace: Record<string, unknown> permissionType: 'admin' | 'write' | 'read' @@ -18,22 +35,33 @@ describe('Workspaces API Route', () => { vi.resetModules() vi.clearAllMocks() userWorkspaces = [] + sessionUserName = 'Bruz Gomez' + insertedValues.length = 0 updateWhereMock.mockResolvedValue([]) updateSetMock.mockReturnValue({ where: updateWhereMock }) updateMock.mockReturnValue({ set: updateSetMock }) + transactionMock.mockImplementation(async (callback) => { + await callback({ + insert: vi.fn((table) => ({ + values: vi.fn(async (values) => { + insertedValues.push({ + table: String(table), + ...(values as Record<string, unknown>), + }) + }), + })), + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(undefined), + }) + }) vi.doMock('@tradinggoose/db', () => ({ db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - innerJoin: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: vi.fn(() => userWorkspaces), - })), - })), - })), - })), + select: vi.fn((selection?: Record<string, unknown>) => + selection && 'workspace' in selection ? userWorkspacesQuery : orphanedWorkflowsQuery + ), update: updateMock, transaction: transactionMock, }, @@ -58,12 +86,12 @@ describe('Workspaces API Route', () => { })) vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ + getSession: vi.fn(async () => ({ user: { id: 'user-1', - name: 'Bruz', + name: sessionUserName, }, - }), + })), })) vi.doMock('@/lib/logs/console/logger', () => ({ @@ -161,4 +189,43 @@ describe('Workspaces API Route', () => { expect(updateMock).not.toHaveBeenCalled() expect(transactionMock).not.toHaveBeenCalled() }) + + it("personalizes the default workspace name from the user's first name", async () => { + const { GET } = await import('@/app/api/workspaces/route') + + const response = await GET(new NextRequest('http://localhost/api/workspaces')) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.workspaces).toHaveLength(1) + expect(data.workspaces[0].name).toBe("Bruz's Workspace") + expect(transactionMock).toHaveBeenCalled() + expect( + insertedValues.some( + (values) => + values.description === + getPublicCopy('en').workspace.defaults.defaultWorkflowDescription && + values.name === 'default-agent' + ) + ).toBe(true) + }) + + it('falls back to localized default workspace copy when no user name is available', async () => { + sessionUserName = null + const copy = getPublicCopy('es') + const { GET } = await import('@/app/api/workspaces/route') + + const response = await GET( + new NextRequest('http://localhost/api/workspaces', { + headers: { + 'x-next-intl-locale': 'es', + }, + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.workspaces).toHaveLength(1) + expect(data.workspaces[0].name).toBe(copy.workspace.defaults.newWorkspaceName) + }) }) diff --git a/apps/tradinggoose/app/api/workspaces/route.ts b/apps/tradinggoose/app/api/workspaces/route.ts index 9a8cd2cac..56f04bea5 100644 --- a/apps/tradinggoose/app/api/workspaces/route.ts +++ b/apps/tradinggoose/app/api/workspaces/route.ts @@ -10,6 +10,8 @@ import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { toWorkspaceApiRecord } from '@/lib/workspaces/billing-owner' import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { getPublicCopy } from '@/i18n/public-copy' +import { defaultLocale, isLocaleCode, type LocaleCode } from '@/i18n/utils' const logger = createLogger('Workspaces') const createWorkspaceSchema = z.object({ @@ -20,6 +22,8 @@ const createWorkspaceSchema = z.object({ export async function GET(request: NextRequest) { const session = await getSession() const allowWorkspaceBootstrap = request.nextUrl.searchParams.get('autoCreate') !== 'false' + const localeHeader = request.headers.get('x-next-intl-locale') ?? '' + const locale: LocaleCode = isLocaleCode(localeHeader) ? localeHeader : defaultLocale if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -42,7 +46,11 @@ export async function GET(request: NextRequest) { } // Create a default workspace for the user - const defaultWorkspace = await createDefaultWorkspace(session.user.id, session.user.name) + const defaultWorkspace = await createDefaultWorkspace( + session.user.id, + session.user.name, + locale + ) // Migrate existing workflows to the default workspace await migrateExistingWorkflows(session.user.id, defaultWorkspace.id) @@ -77,8 +85,10 @@ export async function POST(req: Request) { try { const { name } = createWorkspaceSchema.parse(await req.json()) + const localeHeader = req.headers.get('x-next-intl-locale') ?? '' + const locale: LocaleCode = isLocaleCode(localeHeader) ? localeHeader : defaultLocale - const newWorkspace = await createWorkspace(session.user.id, name) + const newWorkspace = await createWorkspace(session.user.id, name, locale) return NextResponse.json({ workspace: newWorkspace }) } catch (error) { @@ -88,17 +98,24 @@ export async function POST(req: Request) { } // Helper function to create a default workspace -async function createDefaultWorkspace(userId: string, userName?: string | null) { - const firstName = userName?.split(' ')[0] || null - const workspaceName = firstName ? `${firstName}'s Workspace` : 'My Workspace' - return createWorkspace(userId, workspaceName) +async function createDefaultWorkspace( + userId: string, + userName: string | null | undefined, + locale: LocaleCode = defaultLocale +) { + const firstName = userName?.trim().split(/\s+/)[0] + const workspaceName = firstName + ? `${firstName}'s Workspace` + : getPublicCopy(locale).workspace.defaults.newWorkspaceName + return createWorkspace(userId, workspaceName, locale) } // Helper function to create a workspace -async function createWorkspace(userId: string, name: string) { +async function createWorkspace(userId: string, name: string, locale: LocaleCode) { const workspaceId = crypto.randomUUID() const workflowId = crypto.randomUUID() const now = new Date() + const workspaceCopy = getPublicCopy(locale).workspace // Create the workspace and initial workflow in a transaction try { @@ -135,7 +152,7 @@ async function createWorkspace(userId: string, name: string) { workspaceId, folderId: null, name: 'default-agent', - description: 'Your first workflow - start building here!', + description: workspaceCopy.defaults.defaultWorkflowDescription, color: '#3972F6', lastSynced: now, createdAt: now, diff --git a/apps/tradinggoose/app/api/yaml/autolayout/route.ts b/apps/tradinggoose/app/api/yaml/autolayout/route.ts index 108830cc1..403be9a3f 100644 --- a/apps/tradinggoose/app/api/yaml/autolayout/route.ts +++ b/apps/tradinggoose/app/api/yaml/autolayout/route.ts @@ -3,9 +3,6 @@ import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { - resolveAutoLayoutDirection, -} from '@/lib/workflows/workflow-direction' const logger = createLogger('YamlAutoLayoutAPI') @@ -18,13 +15,10 @@ const AutoLayoutRequestSchema = z.object({ }), options: z .object({ - strategy: z.enum(['smart', 'hierarchical', 'layered', 'force-directed']).optional(), - direction: z.enum(['horizontal', 'vertical', 'auto']).optional(), spacing: z .object({ horizontal: z.number().optional(), vertical: z.number().optional(), - layer: z.number().optional(), }) .optional(), alignment: z.enum(['start', 'center', 'end']).optional(), @@ -48,31 +42,21 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Applying auto layout`, { blockCount: Object.keys(workflowState.blocks).length, edgeCount: workflowState.edges.length, - strategy: options?.strategy || 'smart', }) const autoLayoutOptions = { - direction: resolveAutoLayoutDirection( - { - blocks: workflowState.blocks, - edges: workflowState.edges, - }, - options?.direction - ), - horizontalSpacing: options?.spacing?.horizontal || 550, - verticalSpacing: options?.spacing?.vertical || 200, + horizontalSpacing: options?.spacing?.horizontal ?? 550, + verticalSpacing: options?.spacing?.vertical ?? 200, padding: { - x: options?.padding?.x || 150, - y: options?.padding?.y || 150, + x: options?.padding?.x ?? 150, + y: options?.padding?.y ?? 150, }, - alignment: options?.alignment || 'center', + alignment: options?.alignment ?? 'center', } const layoutResult = applyAutoLayout( workflowState.blocks, workflowState.edges, - workflowState.loops || {}, - workflowState.parallels || {}, autoLayoutOptions ) diff --git a/apps/tradinggoose/app/chat/components/error-state/error-state.tsx b/apps/tradinggoose/app/chat/components/error-state/error-state.tsx index dc8bad047..2bba5f63a 100644 --- a/apps/tradinggoose/app/chat/components/error-state/error-state.tsx +++ b/apps/tradinggoose/app/chat/components/error-state/error-state.tsx @@ -1,11 +1,13 @@ 'use client' import { useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { useBrandConfig } from '@/lib/branding/branding' import Nav from '@/app/(landing)/components/nav/nav' import { inter } from '@/app/fonts/inter' import { soehne } from '@/app/fonts/soehne/soehne' +import { localizeHref, type LocaleCode } from '@/i18n/utils' interface ChatErrorStateProps { error: string @@ -14,6 +16,7 @@ interface ChatErrorStateProps { export function ChatErrorState({ error, starCount }: ChatErrorStateProps) { const router = useRouter() + const locale = useLocale() as LocaleCode const brandConfig = useBrandConfig() 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' @@ -40,7 +43,7 @@ export function ChatErrorState({ error, starCount }: ChatErrorStateProps) { <div className='mt-8 w-full'> <Button type='button' - onClick={() => router.push('/workspace')} + onClick={() => router.push(localizeHref(locale, '/workspace'))} className={primaryButtonClasses} > Return to Workspace diff --git a/apps/tradinggoose/app/invite/[id]/invite.tsx b/apps/tradinggoose/app/invite/[id]/invite.tsx index 396fbb4a0..964be1f8c 100644 --- a/apps/tradinggoose/app/invite/[id]/invite.tsx +++ b/apps/tradinggoose/app/invite/[id]/invite.tsx @@ -2,15 +2,18 @@ import { useEffect, useState } from 'react' import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { client, useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { getErrorMessage } from '@/app/invite/[id]/utils' import { InviteLayout, InviteStatusCard } from '@/app/invite/components' +import { localizeHref, type LocaleCode } from '@/i18n/utils' const logger = createLogger('InviteById') export default function Invite() { const router = useRouter() + const locale = useLocale() as LocaleCode const params = useParams() const inviteId = params.id as string const searchParams = useSearchParams() @@ -131,7 +134,10 @@ export default function Invite() { setIsAccepting(true) if (invitationType === 'workspace') { - window.location.href = `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}` + window.location.href = localizeHref( + locale, + `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}` + ) } else { try { // Get the organizationId from invitation details @@ -164,7 +170,7 @@ export default function Invite() { setAccepted(true) setTimeout(() => { - router.push('/workspace') + router.push(localizeHref(locale, '/workspace')) }, 2000) } catch (err: any) { logger.error('Error accepting invitation:', err) @@ -185,7 +191,7 @@ export default function Invite() { } const getCallbackUrl = () => { - return `/invite/${inviteId}${token && token !== inviteId ? `?token=${token}` : ''}` + return localizeHref(locale, `/invite/${inviteId}${token && token !== inviteId ? `?token=${token}` : ''}`) } if (!session?.user && !isPending) { @@ -208,12 +214,16 @@ export default function Invite() { { label: 'Create an account', onClick: () => - router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`), + router.push( + localizeHref(locale, `/signup?callbackUrl=${callbackUrl}&invite_flow=true`) + ), }, { label: 'I already have an account', onClick: () => - router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`), + router.push( + localizeHref(locale, `/login?callbackUrl=${callbackUrl}&invite_flow=true`) + ), variant: 'outline' as const, }, ] @@ -221,18 +231,25 @@ export default function Invite() { { label: 'Sign in', onClick: () => - router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`), + router.push( + localizeHref(locale, `/login?callbackUrl=${callbackUrl}&invite_flow=true`) + ), }, { label: 'Create an account', onClick: () => - router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`), + router.push( + localizeHref( + locale, + `/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true` + ) + ), variant: 'outline' as const, }, ]), { label: 'Return to Home', - onClick: () => router.push('/'), + onClick: () => router.push(localizeHref(locale, '/')), }, ]} /> @@ -269,12 +286,12 @@ export default function Invite() { actions={[ { label: 'Manage Team Settings', - onClick: () => router.push('/workspace'), + onClick: () => router.push(localizeHref(locale, '/workspace')), variant: 'default' as const, }, { label: 'Return to Home', - onClick: () => router.push('/'), + onClick: () => router.push(localizeHref(locale, '/')), variant: 'ghost' as const, }, ]} @@ -297,7 +314,7 @@ export default function Invite() { actions={[ { label: 'Return to Home', - onClick: () => router.push('/'), + onClick: () => router.push(localizeHref(locale, '/')), variant: 'default' as const, }, ]} @@ -318,7 +335,7 @@ export default function Invite() { actions={[ { label: 'Return to Home', - onClick: () => router.push('/'), + onClick: () => router.push(localizeHref(locale, '/')), }, ]} /> @@ -344,7 +361,7 @@ export default function Invite() { }, { label: 'Return to Home', - onClick: () => router.push('/'), + onClick: () => router.push(localizeHref(locale, '/')), variant: 'ghost', }, ]} diff --git a/apps/tradinggoose/app/layout.tsx b/apps/tradinggoose/app/layout.tsx index a4d236288..493ce95a4 100644 --- a/apps/tradinggoose/app/layout.tsx +++ b/apps/tradinggoose/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata, Viewport } from 'next' import Script from 'next/script' import { PUBLIC_ENV_KEY } from 'next-runtime-env' +import { NextIntlClientProvider } from 'next-intl' +import { getLocale, getMessages } from 'next-intl/server' import { generateBrandedMetadata } from '@/lib/branding/metadata' import { createLogger } from '@/lib/logs/console/logger' import { PostHogProvider } from '@/lib/posthog/provider' @@ -75,11 +77,12 @@ function getPublicEnvSnapshot() { ) } -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const [locale, messages] = await Promise.all([getLocale(), getMessages()]) const publicEnv = JSON.stringify(getPublicEnvSnapshot()).replace(/</g, '\\u003c') return ( - <html lang='en' suppressHydrationWarning> + <html lang={locale} suppressHydrationWarning> <head> {/* Basic head hints that are not covered by the Metadata API */} <meta name='color-scheme' content='light dark' /> @@ -97,7 +100,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) <ProviderModelsBootstrap /> <TooltipProvider delayDuration={100} skipDelayDuration={0}> <ZoomPrevention /> - {children} + <NextIntlClientProvider key={locale} locale={locale} messages={messages}> + {children} + </NextIntlClientProvider> </TooltipProvider> </SessionProvider> </QueryProvider> diff --git a/apps/tradinggoose/app/manifest.ts b/apps/tradinggoose/app/manifest.ts index 5a7cc005a..7f07cb3b5 100644 --- a/apps/tradinggoose/app/manifest.ts +++ b/apps/tradinggoose/app/manifest.ts @@ -40,7 +40,7 @@ export default function manifest(): MetadataRoute.Manifest { url: '/workspace', }, ], - lang: 'en-US', + lang: 'en', dir: 'ltr', } } diff --git a/apps/tradinggoose/app/page.tsx b/apps/tradinggoose/app/page.tsx index a1d6d6a11..a0ecd58e0 100644 --- a/apps/tradinggoose/app/page.tsx +++ b/apps/tradinggoose/app/page.tsx @@ -1,93 +1,96 @@ import type { Metadata } from 'next' +import { getLocale } from 'next-intl/server' import { getPublicBillingCatalog } from '@/lib/billing/catalog' import { buildHostedPricingSummary } from '@/lib/billing/public-catalog' import { Background } from '@/app/(landing)/components' import Landing from '@/app/(landing)/landing' -import { DEFAULT_META_DESCRIPTION } from '@/lib/branding/metadata' +import { getPublicCopy } from '@/i18n/public-copy' +import { getOpenGraphLocale, localizePathname, localizeUrl, locales } from '@/i18n/utils' export const dynamic = 'force-dynamic' -const metadataBase: Metadata = { - title: 'TradingGoose - Visual Workflow Platform for LLM Trading | Open Source', - description: DEFAULT_META_DESCRIPTION, - 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, AI trading assistant', - authors: [{ name: 'TradingGoose Studio' }], - creator: 'TradingGoose Studio', - publisher: 'TradingGoose Studio', - formatDetection: { - email: false, - address: false, - telephone: false, - }, - openGraph: { - title: 'TradingGoose - Visual Workflow Platform for LLM Trading', - description: - 'Open-source platform for technical LLM-driven trading. Custom indicators in PineTS, live market monitors, AI agent workflows triggered by market signals.', - type: 'website', - url: 'https://tradinggoose.ai', - siteName: 'TradingGoose', - locale: 'en_US', - images: [ - { +export async function generateMetadata(): Promise<Metadata> { + const locale = (await getLocale()) as (typeof locales)[number] + const billingCatalog = await getPublicBillingCatalog() + const pricingSummary = buildHostedPricingSummary(billingCatalog) + const copy = getPublicCopy(locale) + const seo = copy.meta.landing.seo + const baseUrl = 'https://tradinggoose.ai' + const canonicalPath = localizePathname(locale, '/') + const canonicalUrl = localizeUrl(baseUrl, locale, '/') + const openGraphLocale = getOpenGraphLocale(locale) + + return { + title: copy.meta.landing.title, + description: copy.meta.landing.description, + keywords: seo.keywords, + authors: [{ name: 'TradingGoose Studio' }], + creator: 'TradingGoose Studio', + publisher: 'TradingGoose Studio', + formatDetection: { + email: false, + address: false, + telephone: false, + }, + openGraph: { + title: copy.meta.landing.openGraphTitle, + description: copy.meta.landing.openGraphDescription, + type: 'website', + url: canonicalUrl, + siteName: 'TradingGoose', + locale: openGraphLocale, + alternateLocale: locales.filter((value) => value !== locale).map(getOpenGraphLocale), + images: [ + { + url: '/social-preview.png', + width: 2559, + height: 1398, + alt: seo.socialPreviewAlt, + type: 'image/png', + }, + ], + }, + twitter: { + card: 'summary_large_image', + site: '@tradinggoose', + creator: '@tradinggoose', + title: copy.meta.landing.openGraphTitle, + description: copy.meta.landing.openGraphDescription, + images: { url: '/social-preview.png', - width: 2559, - height: 1398, - alt: 'TradingGoose social preview', - type: 'image/png', + alt: seo.socialPreviewAlt, }, - ], - }, - twitter: { - card: 'summary_large_image', - site: '@tradinggoose', - creator: '@tradinggoose', - title: 'TradingGoose - Visual Workflow Platform for LLM Trading', - description: - 'Open-source platform for technical LLM-driven trading. Custom indicators, live monitors, AI agent workflows triggered by market signals.', - images: { - url: '/social-preview.png', - alt: 'TradingGoose social preview', }, - }, - alternates: { - canonical: 'https://tradinggoose.ai/', - languages: { - 'en-US': 'https://tradinggoose.ai', + alternates: { + canonical: canonicalPath, + languages: { + 'x-default': baseUrl, + en: baseUrl, + es: `${baseUrl}/es`, + 'zh-CN': localizeUrl(baseUrl, 'zh-CN', '/'), + }, }, - }, - robots: { - index: true, - follow: true, - nocache: false, - googleBot: { + robots: { index: true, follow: true, - noimageindex: false, - 'max-video-preview': -1, - 'max-image-preview': 'large', - 'max-snippet': -1, + nocache: false, + googleBot: { + index: true, + follow: true, + noimageindex: false, + 'max-video-preview': -1, + 'max-image-preview': 'large', + 'max-snippet': -1, + }, }, - }, - category: 'finance', - classification: 'Trading Platform', - referrer: 'origin-when-cross-origin', -} - -export async function generateMetadata(): Promise<Metadata> { - const billingCatalog = await getPublicBillingCatalog() - const pricingSummary = buildHostedPricingSummary(billingCatalog) - - return { - ...metadataBase, + category: 'finance', + classification: 'Trading Platform', + referrer: 'origin-when-cross-origin', other: { - 'llm:content-type': - 'visual workflow platform for trading, custom indicators, AI agent workflows for markets', - 'llm:use-cases': - 'signal-driven trade execution, portfolio rebalancing, indicator alerts, strategy backtesting, market sentiment analysis, custom trading dashboards', - 'llm:integrations': - 'OpenAI, Anthropic, Google Gemini, xAI, Mistral, Perplexity, Ollama, custom market data providers', - 'llm:pricing': pricingSummary || 'See hosted pricing on tradinggoose.ai', + 'llm:content-type': seo.llmContentType, + 'llm:use-cases': seo.llmUseCases, + 'llm:integrations': seo.llmIntegrations, + 'llm:pricing': pricingSummary || seo.llmPricing, }, } } diff --git a/apps/tradinggoose/app/sitemap.test.ts b/apps/tradinggoose/app/sitemap.test.ts new file mode 100644 index 000000000..451f597d4 --- /dev/null +++ b/apps/tradinggoose/app/sitemap.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ + +import type { Post } from '@/app/(landing)/blog/lib/types' +import { describe, expect, it, vi } from 'vitest' + +const mockGetBlogPostIndex = vi.fn() +const mockGetPostsFromIndex = vi.fn() + +vi.mock('@/app/(landing)/blog/lib/posts', () => ({ + getBlogPostIndex: (...args: unknown[]) => mockGetBlogPostIndex(...args), + getPostsFromIndex: (...args: unknown[]) => mockGetPostsFromIndex(...args), +})) + +function createPost(slug: string, date: string): Post { + return { + slug, + date, + title: slug, + description: '', + image: '', + content: '', + readingTime: 1, + toc: [], + authors: [], + published: true, + } +} + +describe('sitemap', () => { + it('builds localized blog URLs from a shared blog index', async () => { + const blogIndex = { source: null, candidatesBySlug: new Map() } + + mockGetBlogPostIndex.mockResolvedValue(blogIndex) + mockGetPostsFromIndex.mockImplementation(async (locale: string, index: typeof blogIndex) => { + expect(index).toBe(blogIndex) + + if (locale === 'es') { + return [createPost('es-post', '2026-04-02')] + } + + if (locale === 'zh-CN') { + return [createPost('zh-post', '2026-04-03')] + } + + return [createPost('en-post', '2026-04-01')] + }) + + const { default: sitemap } = await import('./sitemap') + const entries = await sitemap() + + expect(mockGetBlogPostIndex).toHaveBeenCalledTimes(1) + expect(mockGetPostsFromIndex).toHaveBeenCalledTimes(3) + expect(mockGetPostsFromIndex.mock.calls.map(([locale]) => locale)).toEqual( + expect.arrayContaining(['en', 'es', 'zh-CN']) + ) + + const enPost = entries.find((entry) => entry.url === 'https://tradinggoose.ai/blog/en-post') + const esPost = entries.find((entry) => entry.url === 'https://tradinggoose.ai/es/blog/es-post') + const zhPost = entries.find((entry) => entry.url === 'https://tradinggoose.ai/zh/blog/zh-post') + + expect(enPost?.lastModified).toBeInstanceOf(Date) + expect((enPost?.lastModified as Date).toISOString()).toBe('2026-04-01T00:00:00.000Z') + expect(esPost?.lastModified).toBeInstanceOf(Date) + expect((esPost?.lastModified as Date).toISOString()).toBe('2026-04-02T00:00:00.000Z') + expect(zhPost?.lastModified).toBeInstanceOf(Date) + expect((zhPost?.lastModified as Date).toISOString()).toBe('2026-04-03T00:00:00.000Z') + }) +}) diff --git a/apps/tradinggoose/app/sitemap.ts b/apps/tradinggoose/app/sitemap.ts index 38c8c2636..5c4ce308b 100644 --- a/apps/tradinggoose/app/sitemap.ts +++ b/apps/tradinggoose/app/sitemap.ts @@ -1,67 +1,53 @@ import type { MetadataRoute } from 'next' -import { getAllPosts } from '@/app/(landing)/blog/lib/posts' +import { getBlogPostIndex, getPostsFromIndex } from '@/app/(landing)/blog/lib/posts' +import { locales, localizeUrl } from '@/i18n/utils' export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const baseUrl = 'https://tradinggoose.ai' - const posts = await getAllPosts() // 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(), - changeFrequency: 'daily' as const, - priority: 1, - }, - { - url: `${baseUrl}/changelog`, - lastModified: new Date(), - changeFrequency: 'weekly' as const, - priority: 0.8, - }, - { - url: `${baseUrl}/blog`, - lastModified: new Date(), - changeFrequency: 'weekly' as const, - priority: 0.9, - }, - { - url: `${baseUrl}/terms`, - lastModified: new Date(), - changeFrequency: 'monthly' as const, - priority: 0.5, - }, - { - url: `${baseUrl}/privacy`, - lastModified: new Date(), - changeFrequency: 'monthly' as const, - priority: 0.5, - }, - { - url: `${baseUrl}/licenses`, - lastModified: new Date(), - 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', + const localizedRoutes = ['/', '/blog'] as const + // /careers is a live public landing page, so it is intentionally included here. + const englishOnlyRoutes = ['/privacy', '/terms', '/licenses', '/careers', '/changelog'] as const + + const staticPages = locales.flatMap((locale) => + localizedRoutes.map((route) => ({ + url: localizeUrl(baseUrl, locale, route), lastModified: new Date(), - changeFrequency: 'weekly' as const, - priority: 0.9, - }, - ] + changeFrequency: route === '/' ? ('daily' as const) : ('weekly' as const), + priority: route === '/' ? 1 : 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 englishOnlyPages = englishOnlyRoutes.map((route) => ({ + url: `${baseUrl}${route}`, + lastModified: new Date(), + changeFrequency: 'weekly' as const, + priority: route === '/changelog' ? 0.8 : 0.5, })) - return [...staticPages, ...postPages] + const blogIndex = await getBlogPostIndex() + const postPages = ( + await Promise.all( + locales.map(async (locale) => { + const posts = await getPostsFromIndex(locale, blogIndex) + return posts.map((post) => ({ + url: localizeUrl(baseUrl, locale, `/blog/${post.slug}`), + lastModified: new Date(post.date), + changeFrequency: 'monthly' as const, + priority: 0.7, + })) + }) + ) + ).flat() + + const docsPage = { + url: 'https://docs.tradinggoose.ai', + lastModified: new Date(), + changeFrequency: 'weekly' as const, + priority: 0.9, + } + + return [...staticPages, ...englishOnlyPages, docsPage, ...postPages] } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/api-keys.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/api-keys.tsx index b92526be9..abfe2f78c 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/api-keys.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/api-keys.tsx @@ -3,6 +3,7 @@ import { useRef, useState } from 'react' import { KeyRound, Plus, Search } from 'lucide-react' import { useParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { GlobalNavbarHeader } from '@/global-navbar' import { Input } from '@/components/ui' import { Button } from '@/components/ui/button' @@ -13,10 +14,14 @@ import { } from '@/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { cn } from '@/lib/utils' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' export function WorkspaceApiKeysPage() { const params = useParams<{ workspaceId: string }>() const workspaceId = params.workspaceId + const locale = useLocale() as LocaleCode + const apiKeysCopy = getPublicCopy(locale).workspace.apiKeys const [searchTerm, setSearchTerm] = useState('') const [isCardLoading, setIsCardLoading] = useState(true) const [keyScope, setKeyScope] = useState<'workspace' | 'personal'>('workspace') @@ -32,15 +37,13 @@ export function WorkspaceApiKeysPage() { <div className='flex w-full flex-1 items-center gap-3'> <div className='hidden items-center gap-2 sm:flex'> <KeyRound className='h-[18px] w-[18px] text-muted-foreground' /> - <span className='font-medium text-sm'> - API Keys - </span> + <span className='font-medium text-sm'>{apiKeysCopy.title}</span> </div> <div className='flex w-full max-w-xl flex-1'> <div className='flex h-9 w-full items-center gap-2 rounded-lg border bg-background pr-2 pl-3'> <Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} /> <Input - placeholder='Search keys...' + placeholder={apiKeysCopy.searchPlaceholder} value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' @@ -64,7 +67,7 @@ export function WorkspaceApiKeysPage() { )} aria-pressed={keyScope === 'workspace'} > - Workspace + {apiKeysCopy.scope.workspace} </Button> <Button variant='ghost' @@ -78,7 +81,7 @@ export function WorkspaceApiKeysPage() { )} aria-pressed={keyScope === 'personal'} > - Personal + {apiKeysCopy.scope.personal} </Button> </div> ) @@ -89,7 +92,7 @@ export function WorkspaceApiKeysPage() { disabled={(keyScope === 'workspace' && !canManageWorkspaceKeys) || isCardLoading} > <Plus className='h-3.5 w-3.5' /> - <span>Create {keyScope === 'workspace' ? 'Workspace' : 'Personal'} Key</span> + <span>{keyScope === 'workspace' ? apiKeysCopy.create.workspace : apiKeysCopy.create.personal}</span> </PrimaryButton> ) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx index 5854e84b6..f7bb1155b 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx @@ -39,9 +39,12 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Alert, AlertDescription, Button, Input, Label, Skeleton } from '@/components/ui' +import { useLocale } from 'next-intl' import { createLogger } from '@/lib/logs/console/logger' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { cn } from '@/lib/utils' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' interface WorkspaceApiKeysCardProps { workspaceId?: string @@ -98,16 +101,18 @@ const WorkspaceApiKeysCardComponent = ( }: WorkspaceApiKeysCardProps, ref: Ref<WorkspaceApiKeysCardHandle> ) => { + const locale = useLocale() as LocaleCode + const apiKeysCopy = getPublicCopy(locale).workspace.apiKeys const userPermissions = useUserPermissionsContext() const canManageWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin const scope = keyScope const isWorkspaceScope = scope === 'workspace' - const scopeLabel = isWorkspaceScope ? 'Workspace' : 'Personal' + const scopeLabel = isWorkspaceScope ? apiKeysCopy.scope.workspace : apiKeysCopy.scope.personal const scopeLabelLower = scopeLabel.toLowerCase() const scopeDescription = isWorkspaceScope - ? 'Generate and manage workspace-scoped API keys for MCP servers or other integrations.' - : 'Generate and manage personal API keys for MCP servers or other integrations.' + ? apiKeysCopy.labels.workspaceAccess + : apiKeysCopy.labels.personalAccess const [apiKeys, setApiKeys] = useState<ApiKey[]>([]) const [internalSearchTerm, setInternalSearchTerm] = useState('') const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) @@ -198,8 +203,8 @@ const WorkspaceApiKeysCardComponent = ( ) const formatDate = (dateString?: string) => { - if (!dateString) return 'Never' - return new Date(dateString).toLocaleDateString('en-US', { + if (!dateString) return apiKeysCopy.labels.never + return new Date(dateString).toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric', @@ -262,7 +267,7 @@ const WorkspaceApiKeysCardComponent = ( if (!editingKeyId || (isWorkspaceScope && !workspaceId) || !canRenameKeys) return const trimmedName = editingKeyName.trim() if (!trimmedName) { - setRenameError('Name is required') + setRenameError(apiKeysCopy.labels.nameRequired) editKeyNameInputRef.current?.focus() return } @@ -279,7 +284,7 @@ const WorkspaceApiKeysCardComponent = ( const message = typeof errorData?.error === 'string' ? errorData.error - : `Failed to rename ${scopeLabelLower} API key.` + : formatTemplate(apiKeysCopy.labels.failedRename, { scope: scopeLabelLower }) setRenameError(message) editKeyNameInputRef.current?.focus() return @@ -291,7 +296,7 @@ const WorkspaceApiKeysCardComponent = ( void refetchApiKeys() } catch (error) { logger.error('Error renaming API key', { error, scope }) - setRenameError(`Unable to rename ${scopeLabelLower} API key. Please try again.`) + setRenameError(formatTemplate(apiKeysCopy.labels.unableRename, { scope: scopeLabelLower })) editKeyNameInputRef.current?.focus() } finally { setIsUpdatingKeyName(false) @@ -305,7 +310,12 @@ const WorkspaceApiKeysCardComponent = ( const trimmedName = newKeyName.trim() const isDuplicate = apiKeys.some((key) => key.name === trimmedName) if (isDuplicate) { - setCreateError(`A ${scopeLabelLower} API key named "${trimmedName}" already exists.`) + setCreateError( + formatTemplate(apiKeysCopy.labels.duplicateName, { + scope: scopeLabelLower, + name: trimmedName, + }) + ) return } @@ -326,7 +336,7 @@ const WorkspaceApiKeysCardComponent = ( const message = error instanceof Error ? error.message - : `Failed to create ${scopeLabelLower} API key. Please try again.` + : formatTemplate(apiKeysCopy.labels.failedCreate, { scope: scopeLabelLower }) setCreateError(message) } } @@ -371,8 +381,16 @@ const WorkspaceApiKeysCardComponent = ( if (apiKeys.length === 0) { return ( <div className='rounded-2xl border bg-card p-10 text-center shadow-sm'> - <p className='font-medium'>No {scopeLabelLower} API keys yet</p> - <p className='mt-2 text-muted-foreground'>Create one to start integrating right away.</p> + <p className='font-medium'> + {keyScope === 'workspace' + ? apiKeysCopy.emptyState.workspace.title + : apiKeysCopy.emptyState.personal.title} + </p> + <p className='mt-2 text-muted-foreground'> + {keyScope === 'workspace' + ? apiKeysCopy.emptyState.workspace.description + : apiKeysCopy.emptyState.personal.description} + </p> {canManageKeys && ( <Button className='mt-4' @@ -382,7 +400,9 @@ const WorkspaceApiKeysCardComponent = ( }} > <Plus className='mr-2 h-4 w-4 stroke-[2px]' /> - Create Key + {keyScope === 'workspace' + ? apiKeysCopy.emptyState.workspace.button + : apiKeysCopy.emptyState.personal.button} </Button> )} </div> @@ -392,7 +412,10 @@ const WorkspaceApiKeysCardComponent = ( if (resolvedSearchTerm.trim() && filteredKeys.length === 0) { return ( <div className='rounded-xl border border-dashed bg-muted/40 px-6 py-4 text-center text-muted-foreground text-sm'> - No {scopeLabelLower} API keys found matching "{resolvedSearchTerm}". + {formatTemplate(apiKeysCopy.searchEmpty, { + scope: scopeLabelLower, + query: resolvedSearchTerm, + })} </div> ) } @@ -449,7 +472,7 @@ const WorkspaceApiKeysCardComponent = ( disabled={isUpdatingKeyName} > <Check className='h-3.5 w-3.5' /> - <span className='sr-only'>Save API key name</span> + <span className='sr-only'>{apiKeysCopy.labels.saveName}</span> </button> </div> {renameError && ( @@ -461,7 +484,7 @@ const WorkspaceApiKeysCardComponent = ( <div className='space-y-1'> <p className='font-medium'>{key.name}</p> <p className='text-muted-foreground text-xs'> - Last used: {formatDate(key.lastUsed)} + {formatTemplate(apiKeysCopy.labels.lastUsed, { date: formatDate(key.lastUsed) })} </p> </div> {canRenameKeys && ( @@ -472,7 +495,9 @@ const WorkspaceApiKeysCardComponent = ( disabled={isUpdatingKeyName || (isWorkspaceScope && !workspaceId)} > <Pencil className='h-3.5 w-3.5' /> - <span className='sr-only'>Rename {scopeLabelLower} API key</span> + <span className='sr-only'> + {formatTemplate(apiKeysCopy.labels.rename, { scope: scopeLabelLower })} + </span> </button> )} </div> @@ -493,8 +518,8 @@ const WorkspaceApiKeysCardComponent = ( )} <span className='sr-only'> {isRevealed - ? `Hide ${scopeLabelLower} API key` - : `Reveal ${scopeLabelLower} API key`} + ? formatTemplate(apiKeysCopy.labels.hide, { scope: scopeLabelLower }) + : formatTemplate(apiKeysCopy.labels.reveal, { scope: scopeLabelLower })} </span> </button> <div className='max-w-xs'> @@ -511,7 +536,9 @@ const WorkspaceApiKeysCardComponent = ( ) : ( <Copy className='h-3.5 w-3.5' /> )} - <span className='sr-only'>Copy {scopeLabelLower} API key</span> + <span className='sr-only'> + {formatTemplate(apiKeysCopy.labels.copy, { scope: scopeLabelLower })} + </span> </button> <button type='button' @@ -523,7 +550,9 @@ const WorkspaceApiKeysCardComponent = ( }} > <Trash2 className='h-3.5 w-3.5' /> - <span className='sr-only'>Delete {scopeLabelLower} API key</span> + <span className='sr-only'> + {formatTemplate(apiKeysCopy.labels.delete, { scope: scopeLabelLower })} + </span> </button> </div> </div> @@ -567,8 +596,16 @@ const WorkspaceApiKeysCardComponent = ( return ( <tr> <td colSpan={5} className='px-4 py-12 text-center'> - <p className='font-medium text-lg'>No {scopeLabelLower} API keys yet</p> - <p className='mt-2 text-muted-foreground'>Create one to start integrating.</p> + <p className='font-medium text-lg'> + {keyScope === 'workspace' + ? apiKeysCopy.emptyState.workspace.title + : apiKeysCopy.emptyState.personal.title} + </p> + <p className='mt-2 text-muted-foreground'> + {keyScope === 'workspace' + ? apiKeysCopy.emptyState.workspace.description + : apiKeysCopy.emptyState.personal.description} + </p> {canManageKeys && ( <Button className='mt-6' @@ -578,7 +615,9 @@ const WorkspaceApiKeysCardComponent = ( }} > <Plus className='mr-2 h-4 w-4' /> - Create Key + {keyScope === 'workspace' + ? apiKeysCopy.emptyState.workspace.button + : apiKeysCopy.emptyState.personal.button} </Button> )} </td> @@ -590,7 +629,10 @@ const WorkspaceApiKeysCardComponent = ( return ( <tr> <td colSpan={5} className='px-4 py-12 text-center text-muted-foreground'> - No {scopeLabelLower} API keys found matching "{resolvedSearchTerm}". + {formatTemplate(apiKeysCopy.searchEmpty, { + scope: scopeLabelLower, + query: resolvedSearchTerm, + })} </td> </tr> ) @@ -613,7 +655,7 @@ const WorkspaceApiKeysCardComponent = ( <td className='px-4 py-4 text-muted-foreground text-sm text-center'> {formatDate(key.createdAt)} </td> - <td className='px-4 py-4 align-center'> + <td className='px-4 py-4 align-middle'> {canRenameKeys && editingKeyId === key.id ? ( <div className='space-y-2'> <div className='flex max-w-sm items-center gap-2'> @@ -666,8 +708,8 @@ const WorkspaceApiKeysCardComponent = ( )} <span className='sr-only'> {isRevealed - ? `Hide ${scopeLabelLower} API key` - : `Reveal ${scopeLabelLower} API key`} + ? formatTemplate(apiKeysCopy.labels.hide, { scope: scopeLabelLower }) + : formatTemplate(apiKeysCopy.labels.reveal, { scope: scopeLabelLower })} </span> </Button> <div className='min-w-0 flex-1'> @@ -686,7 +728,9 @@ const WorkspaceApiKeysCardComponent = ( ) : ( <Copy className='h-4 w-4' /> )} - <span className='sr-only'>Copy {scopeLabelLower} API key</span> + <span className='sr-only'> + {formatTemplate(apiKeysCopy.labels.copy, { scope: scopeLabelLower })} + </span> </Button> </div> </td> @@ -694,7 +738,7 @@ const WorkspaceApiKeysCardComponent = ( {formatDate(key.lastUsed)} </td> <td className='px-4 py-4'> - <div className='flex items-center justify-centergap-1.5'> + <div className='flex items-center justify-center gap-1.5'> {isEditing ? ( <> <Button @@ -706,7 +750,9 @@ const WorkspaceApiKeysCardComponent = ( onClick={() => void commitEditingKey()} > <Check className='h-4 w-4' /> - <span className='sr-only'>Save {scopeLabelLower} API key</span> + <span className='sr-only'> + {formatTemplate(apiKeysCopy.labels.save, { scope: scopeLabelLower })} + </span> </Button> <Button type='button' @@ -717,7 +763,7 @@ const WorkspaceApiKeysCardComponent = ( onClick={cancelEditingKey} > <X className='h-4 w-4' /> - <span className='sr-only'>Cancel rename</span> + <span className='sr-only'>{apiKeysCopy.labels.cancelRename}</span> </Button> </> ) : ( @@ -732,7 +778,9 @@ const WorkspaceApiKeysCardComponent = ( onClick={() => startEditingKey(key)} > <Pencil className='h-4 w-4' /> - <span className='sr-only'>Rename {scopeLabelLower} API key</span> + <span className='sr-only'> + {formatTemplate(apiKeysCopy.labels.rename, { scope: scopeLabelLower })} + </span> </Button> )} <Button @@ -747,7 +795,9 @@ const WorkspaceApiKeysCardComponent = ( }} > <Trash2 className='h-4 w-4' /> - <span className='sr-only'>Delete {scopeLabelLower} API key</span> + <span className='sr-only'> + {formatTemplate(apiKeysCopy.labels.delete, { scope: scopeLabelLower })} + </span> </Button> </> )} @@ -773,27 +823,27 @@ const WorkspaceApiKeysCardComponent = ( <tr> <th className='px-4 pt-2 pb-3 text-center font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Created At + {apiKeysCopy.headers.createdAt} </span> </th> <th className='px-4 pt-2 pb-3 text-center font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Name + {apiKeysCopy.headers.name} </span> </th> <th className='px-4 pt-2 pb-3 text-center font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Key + {apiKeysCopy.headers.key} </span> </th> <th className='px-4 pt-2 pb-3 text-center font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Last Update + {apiKeysCopy.headers.lastUpdate} </span> </th> <th className='px-4 pt-2 pb-3 text-center font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Actions + {apiKeysCopy.headers.actions} </span> </th> </tr> @@ -822,7 +872,7 @@ const WorkspaceApiKeysCardComponent = ( <Alert variant='destructive'> <AlertCircle className='h-4 w-4' /> <AlertDescription> - Unable to determine workspace. Please refresh the page and try again. + {apiKeysCopy.labels.unableToDetermineWorkspace} </AlertDescription> </Alert> ) @@ -850,7 +900,7 @@ const WorkspaceApiKeysCardComponent = ( isCardVariant ? 'border-t px-6 py-3' : 'px-1 pt-3' )} > - You need edit or admin access to manage workspace API keys. + {apiKeysCopy.labels.workspacePermissions} </div> ) : null @@ -861,14 +911,16 @@ const WorkspaceApiKeysCardComponent = ( {shouldRenderHeader && ( <div className='flex flex-col gap-4 border-b px-6 py-5 md:flex-row md:items-center md:justify-between'> <div> - <h2 className='font-semibold text-lg'>{scopeLabel} API Keys</h2> + <h2 className='font-semibold text-lg'> + {formatTemplate(apiKeysCopy.cardTitle, { scope: scopeLabel })} + </h2> <p className='text-muted-foreground text-sm'>{scopeDescription}</p> </div> <div className='flex flex-col gap-3 sm:flex-row sm:items-center'> <div className='flex h-9 items-center gap-2 rounded-lg border bg-background pr-2 pl-3 sm:w-60'> <Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} /> <Input - placeholder='Search keys...' + placeholder={apiKeysCopy.searchPlaceholder} value={resolvedSearchTerm} onChange={(e) => handleSearchTermChange(e.target.value)} className='flex-1 border-0 bg-transparent px-0 text-sm focus-visible:ring-0 focus-visible:ring-offset-0' @@ -882,7 +934,9 @@ const WorkspaceApiKeysCardComponent = ( disabled={!canManageKeys} > <Plus className='mr-2 h-4 w-4' /> - Create Key + {keyScope === 'workspace' + ? apiKeysCopy.create.workspace + : apiKeysCopy.create.personal} </Button> </div> </div> @@ -901,19 +955,19 @@ const WorkspaceApiKeysCardComponent = ( <AlertDialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}> <AlertDialogContent className='rounded-md sm:max-w-md'> <AlertDialogHeader> - <AlertDialogTitle>Create {scopeLabelLower} API key</AlertDialogTitle> + <AlertDialogTitle> + {formatTemplate(apiKeysCopy.dialogs.createTitle, { scope: scopeLabelLower })} + </AlertDialogTitle> <AlertDialogDescription> - {isWorkspaceScope - ? '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.' - : '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.'} + {scopeDescription} </AlertDialogDescription> </AlertDialogHeader> <div className='space-y-2'> - <Label>Name</Label> + <Label>{apiKeysCopy.dialogs.createNameLabel}</Label> <Input autoFocus - placeholder='e.g., Production MCP Server' + placeholder={apiKeysCopy.dialogs.createNamePlaceholder} value={newKeyName} onChange={(e) => { setNewKeyName(e.target.value) @@ -931,7 +985,7 @@ const WorkspaceApiKeysCardComponent = ( setCreateError(null) }} > - Cancel + {apiKeysCopy.dialogs.cancel} </AlertDialogCancel> <AlertDialogAction className='w-full rounded-sm' @@ -940,7 +994,7 @@ const WorkspaceApiKeysCardComponent = ( } onClick={handleCreateKey} > - Create Key + {apiKeysCopy.dialogs.createButton} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> @@ -958,9 +1012,11 @@ const WorkspaceApiKeysCardComponent = ( > <AlertDialogContent className='rounded-md sm:max-w-md'> <AlertDialogHeader> - <AlertDialogTitle>Your {scopeLabelLower} API key</AlertDialogTitle> + <AlertDialogTitle> + {formatTemplate(apiKeysCopy.dialogs.newKeyTitle, { scope: scopeLabelLower })} + </AlertDialogTitle> <AlertDialogDescription> - This is the only time you will see the full key. Copy and store it securely. + {apiKeysCopy.dialogs.newKeyDescription} </AlertDialogDescription> </AlertDialogHeader> @@ -976,6 +1032,7 @@ const WorkspaceApiKeysCardComponent = ( onClick={() => copyToClipboard(newKey.key)} > {copySuccess ? <Check className='h-3.5 w-3.5' /> : <Copy className='h-3.5 w-3.5' />} + <span className='sr-only'>{apiKeysCopy.dialogs.copyToClipboard}</span> </Button> </div> )} @@ -985,22 +1042,24 @@ const WorkspaceApiKeysCardComponent = ( <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> <AlertDialogContent className='rounded-md sm:max-w-md'> <AlertDialogHeader> - <AlertDialogTitle>Delete {scopeLabelLower} API key?</AlertDialogTitle> + <AlertDialogTitle> + {formatTemplate(apiKeysCopy.dialogs.deleteTitle, { scope: scopeLabelLower })} + </AlertDialogTitle> <AlertDialogDescription> - This will immediately revoke access for any integrations using this key. + {apiKeysCopy.dialogs.deleteDescription} </AlertDialogDescription> </AlertDialogHeader> {deleteKey && ( <div className='py-2'> <p className='mb-2 text-sm'> - Type <span className='font-semibold'>{deleteKey.name}</span> to confirm. + {formatTemplate(apiKeysCopy.dialogs.deletePrompt, { name: deleteKey.name })} </p> <Input autoFocus value={deleteConfirmationName} onChange={(e) => setDeleteConfirmationName(e.target.value)} - placeholder='API key name' + placeholder={apiKeysCopy.dialogs.deletePlaceholder} /> </div> )} @@ -1013,14 +1072,14 @@ const WorkspaceApiKeysCardComponent = ( setDeleteConfirmationName('') }} > - Cancel + {apiKeysCopy.dialogs.cancel} </AlertDialogCancel> <AlertDialogAction className='w-full rounded-sm bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600' disabled={!deleteKey || deleteConfirmationName !== deleteKey.name} onClick={handleDeleteKey} > - Delete Key + {apiKeysCopy.dialogs.deleteButton} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> 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 70d418eb6..d17c249fe 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/components/use-keyboard-shortcuts.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/components/use-keyboard-shortcuts.ts @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import { useRouter } from 'next/navigation' +import { localizeHref, stripLocaleFromPathname } from '@/i18n/utils' /** * Detect if the current platform is Mac @@ -96,14 +97,15 @@ export function useGlobalShortcuts() { ) { event.preventDefault() - const pathParts = window.location.pathname.split('/') + const { locale, pathname } = stripLocaleFromPathname(window.location.pathname) + const pathParts = pathname.split('/') const workspaceIndex = pathParts.indexOf('workspace') if (workspaceIndex !== -1 && pathParts[workspaceIndex + 1]) { const workspaceId = pathParts[workspaceIndex + 1] - router.push(`/workspace/${workspaceId}/logs`) + router.push(localizeHref(locale, `/workspace/${workspaceId}/logs`)) } else { - router.push('/workspace') + router.push(localizeHref(locale, '/workspace')) } } } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.test.tsx index db0240283..5f8265caf 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.test.tsx @@ -229,7 +229,74 @@ describe('DashboardClient', () => { consoleError.mockRestore() }) - it('preserves persisted review targets independently from ambient current ids during hydration', async () => { + it('fills missing shared workflow state when switching a gray widget into a partially populated color', async () => { + await act(async () => { + root.render( + <DashboardClient + initialState={{ + id: 'group-root', + type: 'group', + direction: 'horizontal', + sizes: [50, 50], + children: [ + createPanelLayout('panel-a', 'wf-local'), + { + id: 'panel-b', + type: 'panel', + widget: { + key: 'data_chart', + pairColor: 'red', + params: null, + }, + }, + ], + }} + workspaceId='ws-a' + layoutId='layout-a' + initialLayouts={createLayouts('layout-a')} + initialColorPairs={{ + pairs: [ + { + color: 'red', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }, + ], + }} + /> + ) + }) + + const switchToRedButton = container.querySelector('[data-testid="pair-color-red-panel-a"]') + if (!(switchToRedButton instanceof HTMLButtonElement)) { + throw new Error('Expected pair color switch button to be rendered') + } + + await act(async () => { + switchToRedButton.click() + }) + + expect(usePairColorStore.getState().contexts.red).toEqual({ + workflowId: 'wf-local', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }) + expect(readWidgetSurface(container, 'panel-a')).toEqual({ + workflowId: '', + workspaceId: 'ws-a', + pairColor: 'red', + }) + }) + + it('ignores persisted review targets during color-pair hydration', async () => { await act(async () => { root.render( <DashboardClient @@ -259,12 +326,6 @@ describe('DashboardClient', () => { expect(usePairColorStore.getState().contexts.red).toMatchObject({ workflowId: 'wf-current', skillId: 'skill-saved', - reviewTarget: { - reviewSessionId: 'review-draft-skill', - reviewEntityKind: 'skill', - reviewEntityId: null, - reviewDraftSessionId: 'draft-skill', - }, }) }) @@ -410,8 +471,11 @@ function createLayouts(layoutId: string): LayoutTab[] { ] } -function readWidgetSurface(container: HTMLDivElement) { - const element = container.querySelector('[data-testid^="widget-surface-"]') +function readWidgetSurface(container: HTMLDivElement, panelId?: string) { + const selector = panelId + ? `[data-testid="widget-surface-${panelId}"]` + : '[data-testid^="widget-surface-"]' + const element = container.querySelector(selector) if (!(element instanceof HTMLElement)) { throw new Error('Expected widget surface to be rendered') } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx index 1810432a6..76634aa83 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx @@ -22,7 +22,13 @@ import { import { usePathname, useRouter } from 'next/navigation' import { Input } from '@/components/ui/input' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' -import { useBrandConfig } from '@/lib/branding/branding' +import { getPublicCopy } from '@/i18n/public-copy' +import { + buildLocaleRequestHeaders, + localizeDocsUrl, + localizeHref, + stripLocaleFromPathname, +} from '@/i18n/utils' import { type ListingIdentity, type ListingInputValue, @@ -50,6 +56,7 @@ import { type WidgetInstance, } from '@/widgets/layout' import { isPairColor, PAIR_COLORS, type PairColor } from '@/widgets/pair-colors' +import type { LocaleCode } from '@/i18n/utils' import type { WidgetRuntimeContext } from '@/widgets/types' import { WidgetSurface } from '@/widgets/widget-surface' @@ -72,6 +79,7 @@ interface DashboardNodeProps { node: LayoutNode persistGroup: (id: string, sizes: number[]) => void widgetContext: WidgetRuntimeContext + locale: LocaleCode updatePairColor: (panelId: string, color: PairColor) => void updateWidget: (panelId: string, widgetKey: string) => void updateWidgetParams: (panelId: string, params: Record<string, unknown> | null) => void @@ -96,12 +104,13 @@ interface DropdownItem { const DashboardNode = memo( function DashboardNode({ - node, - persistGroup, - widgetContext, - updatePairColor, - updateWidget, - updateWidgetParams, + node, + persistGroup, + widgetContext, + locale, + updatePairColor, + updateWidget, + updateWidgetParams, sizeHint, availableWidth = 100, availableHeight = 100, @@ -117,6 +126,7 @@ const DashboardNode = memo( <WidgetSurface widget={node.widget} context={widgetContext} + locale={locale} panelId={node.id} onPairColorChange={(color) => updatePairColor(node.id, color)} onWidgetChange={(key) => updateWidget(node.id, key)} @@ -158,6 +168,7 @@ const DashboardNode = memo( node={child} persistGroup={persistGroup} widgetContext={widgetContext} + locale={locale} updatePairColor={updatePairColor} updateWidget={updateWidget} updateWidgetParams={updateWidgetParams} @@ -180,7 +191,8 @@ const DashboardNode = memo( prev.node === next.node && prev.sizeHint === next.sizeHint && prev.availableWidth === next.availableWidth && - prev.availableHeight === next.availableHeight + prev.availableHeight === next.availableHeight && + prev.locale === next.locale ) export function DashboardClient({ @@ -213,6 +225,8 @@ export function DashboardClient({ const isCreatingLayoutRef = useRef(false) const pathname = usePathname() const router = useRouter() + const locale = stripLocaleFromPathname(pathname ?? '/').locale + const workspaceCopy = getPublicCopy(locale).workspace const [docs, setDocs] = useState<DropdownItem[]>([]) const [searchWorkspaces, setSearchWorkspaces] = useState<DropdownItem[]>([]) const [searchQuery, setSearchQuery] = useState('') @@ -220,7 +234,6 @@ export function DashboardClient({ const searchContainerRef = useRef<HTMLDivElement | null>(null) const docsLoadedRef = useRef(false) const docsLoadingRef = useRef(false) - const brand = useBrandConfig() const { knowledgeBases } = useKnowledgeBasesList(workspaceId) const applyLayoutData = useCallback( @@ -296,7 +309,9 @@ export function DashboardClient({ const loadWorkspacesForSearch = async () => { try { - const response = await fetch('/api/workspaces') + const response = await fetch('/api/workspaces', { + headers: buildLocaleRequestHeaders(locale), + }) if (!response.ok) { throw new Error(`Failed to load workspaces (${response.status})`) } @@ -310,7 +325,7 @@ export function DashboardClient({ (workspace: { id: string; name: string }): DropdownItem => ({ id: workspace.id, name: workspace.name, - href: `/workspace/${workspace.id}/dashboard`, + href: localizeHref(locale, `/workspace/${workspace.id}/dashboard`), }) ) ) @@ -326,7 +341,7 @@ export function DashboardClient({ return () => { isMounted = false } - }, []) + }, [locale]) useEffect(() => { if (hydratedDashboardIdentityRef.current === dashboardIdentity) { @@ -442,11 +457,13 @@ export function DashboardClient({ const handlePairColorChange = useCallback((panelId: string, color: PairColor) => { const currentTree = latestLayoutRef.current - const previousColor = findPanelPairColor(currentTree, panelId) + const currentWidget = findPanelWidget(currentTree, panelId) + const previousColor = isPairColor(currentWidget?.pairColor) ? currentWidget.pairColor : undefined if (previousColor === color) { return } + seedPairContextForColorSwitch(previousColor, color, currentWidget) const nextTree = updatePanelPairColor(currentTree, panelId, color) if (nextTree === currentTree) { return @@ -454,7 +471,6 @@ export function DashboardClient({ latestLayoutRef.current = nextTree setTree(nextTree) - clonePairContextIfEmpty(previousColor, color) }, []) const searchKnowledgeBases = useMemo( @@ -463,34 +479,39 @@ export function DashboardClient({ id: kb.id, name: kb.name, description: kb.description, - href: `/workspace/${workspaceId}/knowledge/${kb.id}`, + href: localizeHref(locale, `/workspace/${workspaceId}/knowledge/${kb.id}`), })), - [knowledgeBases, workspaceId] + [knowledgeBases, locale, workspaceId] ) const pages = useMemo( () => [ - { id: 'logs', name: 'Logs', icon: ScrollText, href: `/workspace/${workspaceId}/logs` }, + { + id: 'logs', + name: workspaceCopy.dashboard.pages.logs, + icon: ScrollText, + href: localizeHref(locale, `/workspace/${workspaceId}/logs`), + }, { id: 'knowledge', - name: 'Knowledge', + name: workspaceCopy.dashboard.pages.knowledge, icon: LibraryBig, - href: `/workspace/${workspaceId}/knowledge`, + href: localizeHref(locale, `/workspace/${workspaceId}/knowledge`), }, { id: 'templates', - name: 'Templates', + name: workspaceCopy.dashboard.pages.templates, icon: Shapes, - href: `/workspace/${workspaceId}/templates`, + href: localizeHref(locale, `/workspace/${workspaceId}/templates`), }, { id: 'docs', - name: 'Docs', + name: workspaceCopy.dashboard.pages.docs, icon: BookOpen, - href: brand.documentationUrl, + href: localizeDocsUrl(locale), }, ], - [brand.documentationUrl, workspaceId] + [locale, workspaceCopy, workspaceId] ) const loadDocs = useCallback(async () => { @@ -741,12 +762,12 @@ export function DashboardClient({ <div className='flex w-full flex-1 items-center gap-3'> <div className='hidden items-center gap-2 sm:flex'> <LayoutTemplate className='h-[18px] w-[18px] text-muted-foreground' /> - <span className='font-medium text-sm'>Dashboard</span> + <span className='font-medium text-sm'>{workspaceCopy.dashboard.title}</span> </div> <div ref={searchContainerRef} className='relative flex flex-1'> <Search className='-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground' /> <Input - placeholder='Search workspace content...' + placeholder={workspaceCopy.dashboard.searchPlaceholder} value={searchQuery} onChange={(event) => { setSearchQuery(event.target.value) @@ -765,7 +786,7 @@ export function DashboardClient({ <div className='max-h-80 overflow-y-auto'> <div className='space-y-2 p-2'> <DropdownSection - title='Workspaces' + title={workspaceCopy.dashboard.sections.workspaces} icon={Building2} items={filteredWorkspaces} onSelect={(href) => { @@ -775,7 +796,7 @@ export function DashboardClient({ }} /> <DropdownSection - title='Knowledge Bases' + title={workspaceCopy.dashboard.sections.knowledgeBases} icon={LibraryBig} items={filteredKnowledgeBases} onSelect={(href) => { @@ -785,7 +806,7 @@ export function DashboardClient({ }} /> <DropdownSection - title='Pages' + title={workspaceCopy.dashboard.sections.pages} icon={ScrollText} items={filteredPages} onSelect={(href) => { @@ -797,7 +818,7 @@ export function DashboardClient({ {filteredDocs.length > 0 && ( <section> <div className='mb-2 text-muted-foreground/70 text-xs uppercase tracking-wide'> - Docs + {workspaceCopy.dashboard.sections.docs} </div> <div className='space-y-1'> {filteredDocs.map((doc) => ( @@ -832,7 +853,9 @@ export function DashboardClient({ </section> )} {!hasResults && ( - <div className='text-muted-foreground text-sm'>No matching content</div> + <div className='text-muted-foreground text-sm'> + {workspaceCopy.dashboard.emptySearch} + </div> )} </div> </div> @@ -851,6 +874,7 @@ export function DashboardClient({ onCreate={handleAddLayout} onRename={handleRenameLayout} onDelete={handleDeleteLayout} + createButtonLabel={workspaceCopy.layoutTabs.createNewLayout} /> ) @@ -862,6 +886,7 @@ export function DashboardClient({ node={tree} persistGroup={persistGroup} widgetContext={widgetContext} + locale={locale} updatePairColor={handlePairColorChange} updateWidget={handleWidgetChange} updateWidgetParams={handleWidgetParamsChange} @@ -1169,9 +1194,7 @@ function applyPairDataToWidget( 'reviewDraftSessionId', ] as const - const hasPairData = - pairKeys.some((k) => pairData[k] != null) || - reviewKeys.some((k) => pairData.reviewTarget?.[k] != null) + const hasPairData = pairKeys.some((k) => pairData[k] != null) const hasPairParams = pairKeys.some((k) => k in baseParams) || reviewKeys.some((k) => k in baseParams) || @@ -1185,7 +1208,7 @@ function applyPairDataToWidget( baseParams[key] = pairData[key] ?? undefined } for (const key of reviewKeys) { - baseParams[key] = pairData.reviewTarget?.[key] ?? undefined + baseParams[key] = undefined } const nextParams = Object.keys(baseParams).length > 0 ? baseParams : null @@ -1201,7 +1224,6 @@ function applyPairDataToWidget( } function hydratePairStoreFromColorPairs(colorPairs: PersistedColorPairsState) { - const now = Date.now() const currentContexts = usePairColorStore.getState().contexts const nextContexts: Record<PairColor, PairColorContext> = { ...currentContexts } @@ -1216,13 +1238,11 @@ function hydratePairStoreFromColorPairs(colorPairs: PersistedColorPairsState) { ...normalizePairColorContext({ workflowId: pair.workflowId ?? undefined, listing: pair.listing ?? null, - reviewTarget: pair.reviewTarget, indicatorId: pair.indicatorId ?? null, mcpServerId: pair.mcpServerId ?? null, customToolId: pair.customToolId ?? null, skillId: pair.skillId ?? null, }), - updatedAt: now, } } @@ -1248,12 +1268,6 @@ function buildPersistedColorPairs(layout: LayoutNode): PersistedColorPairsState color, workflowId, listing, - reviewTarget: { - reviewSessionId: normalizeOptionalString(context?.reviewTarget?.reviewSessionId), - reviewEntityKind: normalizeOptionalString(context?.reviewTarget?.reviewEntityKind), - reviewEntityId: normalizeOptionalString(context?.reviewTarget?.reviewEntityId), - reviewDraftSessionId: normalizeOptionalString(context?.reviewTarget?.reviewDraftSessionId), - }, indicatorId, mcpServerId, customToolId, @@ -1266,17 +1280,13 @@ function buildPersistedColorPairs(layout: LayoutNode): PersistedColorPairsState function hasLinkedColorPairs(colorPairs?: PersistedColorPairsState): boolean { if (!colorPairs || !Array.isArray(colorPairs.pairs)) return false - return colorPairs.pairs.some( - (pair) => - pair?.color && - (pair.workflowId || - pair.reviewTarget?.reviewSessionId || - Boolean(getListingIdentity(pair.listing)) || - pair.indicatorId || - pair.mcpServerId || - pair.customToolId || - pair.skillId) - ) + return colorPairs.pairs.some((pair) => { + if (!pair?.color) { + return false + } + + return Object.keys(normalizePairColorContext(pair)).length > 0 + }) } function getListingIdentity(listing?: ListingInputValue | null): ListingIdentity | null { @@ -1306,41 +1316,68 @@ function cleanupUnusedPairContexts(layout: LayoutNode) { if (color === 'gray') return if (colorsInUse.has(color)) return const context = contexts[color] - if (hasContextData(context)) { + if (Object.keys(normalizePairColorContext(context)).length > 0) { resetContext(color) } }) } -function clonePairContextIfEmpty(previousColor: PairColor | undefined, nextColor: PairColor) { - if (!previousColor || previousColor === 'gray') return - if (nextColor === 'gray' || nextColor === previousColor) return +function seedPairContextForColorSwitch( + previousColor: PairColor | undefined, + nextColor: PairColor, + currentWidget: WidgetInstance +) { + if (nextColor === 'gray' || nextColor === previousColor) { + return + } const { contexts, setContext } = usePairColorStore.getState() - const source = contexts[previousColor] - const target = contexts[nextColor] + const target = normalizePairColorContext(contexts[nextColor]) + const source = + previousColor && previousColor !== 'gray' + ? normalizePairColorContext(contexts[previousColor]) + : normalizePairColorContext(currentWidget?.params ?? null) + const nextContext: PairColorContext = {} + + if (source.workflowId && !target.workflowId) { + nextContext.workflowId = source.workflowId + } + if (source.listing && !target.listing) { + nextContext.listing = source.listing + } + if (source.indicatorId && !target.indicatorId) { + nextContext.indicatorId = source.indicatorId + } + if (source.mcpServerId && !target.mcpServerId) { + nextContext.mcpServerId = source.mcpServerId + } + if (source.customToolId && !target.customToolId) { + nextContext.customToolId = source.customToolId + } + if (source.skillId && !target.skillId) { + nextContext.skillId = source.skillId + } - if (!hasContextData(source) || hasContextData(target)) { + if (Object.keys(nextContext).length === 0) { return } - setContext(nextColor, { ...source }) + setContext(nextColor, nextContext) } -function findPanelPairColor(node: LayoutNode, panelId: string): PairColor | undefined { +function findPanelWidget(node: LayoutNode, panelId: string): WidgetInstance { if (node.type === 'panel') { - if (node.id === panelId) { - return isPairColor(node.widget?.pairColor) ? node.widget?.pairColor : undefined - } - return undefined + return node.id === panelId ? node.widget : null } for (const child of node.children) { - const color = findPanelPairColor(child, panelId) - if (color) return color + const widget = findPanelWidget(child, panelId) + if (widget) { + return widget + } } - return undefined + return null } function findParentGroupId( @@ -1362,11 +1399,6 @@ function findParentGroupId( return null } -function hasContextData(context?: PairColorContext): boolean { - if (!context) return false - return Object.keys(context).length > 0 -} - function splitPanelIntoVerticalGroup(node: LayoutNode, panelId: string): LayoutNode { return splitPanelIntoGroup(node, panelId, 'vertical') } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/layout-tabs.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/layout-tabs.tsx index d9c1d5c8a..eb317fc11 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/layout-tabs.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/layout-tabs.tsx @@ -22,6 +22,7 @@ interface LayoutTabsProps { onCreate: () => void onRename: (layoutId: string, name: string) => void onDelete: (layoutId: string) => void + createButtonLabel: string } export function LayoutTabs({ @@ -32,6 +33,7 @@ export function LayoutTabs({ onCreate, onRename, onDelete, + createButtonLabel, }: LayoutTabsProps) { const tabsScrollRef = useRef<HTMLDivElement>(null) const inputRef = useRef<HTMLInputElement>(null) @@ -207,7 +209,7 @@ export function LayoutTabs({ disabled={isBusy} > <Plus className='h-3.5 w-3.5' /> - <span className='sr-only'>Create new layout</span> + <span className='sr-only'>{createButtonLabel}</span> </button> </div> <SortableOverlay> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/page.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/page.tsx index 536bedfb5..9ed2de968 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/page.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/page.tsx @@ -2,8 +2,11 @@ import { randomUUID } from 'crypto' import { db } from '@tradinggoose/db' import { layoutMap } from '@tradinggoose/db/schema' import { and, asc, eq } from 'drizzle-orm' +import { headers } from 'next/headers' import { getSession } from '@/lib/auth' import { hydrateDashboardListingData } from '@/lib/listing/hydrate-ui' +import { getPublicCopy } from '@/i18n/public-copy' +import { defaultLocale, isLocaleCode, type LocaleCode } from '@/i18n/utils' import { DashboardClient } from '@/app/workspace/[workspaceId]/dashboard/dashboard-client' import { createDefaultColorPairsState, @@ -21,6 +24,11 @@ export default async function WorkspaceDashboardPage({ const { workspaceId } = await params const resolvedSearchParams = searchParams ? await searchParams : undefined const requestedLayoutId = resolvedSearchParams?.layoutId + const requestHeaders = await headers() + const localeHeader = requestHeaders.get('x-next-intl-locale') + const resolvedLocale = localeHeader ?? '' + const locale: LocaleCode = isLocaleCode(resolvedLocale) ? resolvedLocale : defaultLocale + const workspaceCopy = getPublicCopy(locale).workspace const session = await getSession() if (!session?.user?.id) { @@ -46,7 +54,7 @@ export default async function WorkspaceDashboardPage({ id: randomUUID(), workspaceId, userId, - name: 'Default Layout', + name: workspaceCopy.defaults.defaultLayoutName, sort_order: 0, layout: serializeLayout(defaultLayout), color_pair: defaultColorPairs, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/environment/components/environment-variables.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/environment/components/environment-variables.tsx index d1c50ce60..e67dd9ed2 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/environment/components/environment-variables.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/environment/components/environment-variables.tsx @@ -11,6 +11,7 @@ import { } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Check, Copy, Eye, EyeOff, Pencil, Plus, Trash2, X } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' @@ -24,6 +25,8 @@ import { useUpsertWorkspaceEnvironment, useWorkspaceEnvironment, } from '@/hooks/queries/environment' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import type { EnvironmentVariable } from '@/stores/settings/environment/types' type Scope = 'workspace' | 'personal' @@ -60,11 +63,11 @@ export interface EnvironmentVariablesHandle { const logger = createLogger('EnvironmentVariables') -const formatDateTime = (value?: string | null): string => { +const formatDateTime = (value?: string | null, locale = 'en'): string => { if (!value) return '—' try { - return new Intl.DateTimeFormat('en', { + return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', year: 'numeric', @@ -190,6 +193,8 @@ const EnvironmentVariablesComponent = ( }: EnvironmentVariablesProps, ref: Ref<EnvironmentVariablesHandle> ) => { + const locale = useLocale() as LocaleCode + const environmentCopy = getPublicCopy(locale).workspace.environment const queryClient = useQueryClient() const { data, isPending: isWorkspaceLoading } = useWorkspaceEnvironment(workspaceId) const upsertWorkspaceMutation = useUpsertWorkspaceEnvironment() @@ -409,8 +414,6 @@ const EnvironmentVariablesComponent = ( } } - const scopeLabel = keyScope === 'workspace' ? 'Workspace' : 'Personal' - const renderRows = () => { if (isWorkspaceLoading && rowsForScope.length === 0) { return [0, 1, 2].map((index) => ( @@ -441,11 +444,21 @@ const EnvironmentVariablesComponent = ( return ( <tr> <td colSpan={5} className='px-4 py-12 text-center'> - <p className='font-medium text-lg'>No {scopeLabel.toLowerCase()} variables yet</p> - <p className='mt-2 text-muted-foreground'>Create one to start configuring.</p> + <p className='font-medium text-lg'> + {keyScope === 'workspace' + ? environmentCopy.emptyState.workspace.title + : environmentCopy.emptyState.personal.title} + </p> + <p className='mt-2 text-muted-foreground'> + {keyScope === 'workspace' + ? environmentCopy.emptyState.workspace.description + : environmentCopy.emptyState.personal.description} + </p> <Button className='mt-6' onClick={() => addVariable(keyScope)}> <Plus className='mr-2 h-4 w-4' /> - Create {scopeLabel} Environment Variable + {keyScope === 'workspace' + ? environmentCopy.create.workspace + : environmentCopy.create.personal} </Button> </td> </tr> @@ -456,7 +469,12 @@ const EnvironmentVariablesComponent = ( return ( <tr> <td colSpan={5} className='px-4 py-12 text-center text-muted-foreground'> - No {scopeLabel.toLowerCase()} environment variables found matching "{searchTerm}". + {formatTemplate( + keyScope === 'workspace' + ? environmentCopy.searchEmpty.workspace + : environmentCopy.searchEmpty.personal, + { query: searchTerm } + )} </td> </tr> ) @@ -471,7 +489,7 @@ const EnvironmentVariablesComponent = ( return ( <tr key={row.id} className='border-b transition-colors hover:bg-card/30'> <td className='px-4 py-2 align-middle text-muted-foreground text-sm'> - {formatDateTime(row.createdAt)} + {formatDateTime(row.createdAt, locale)} </td> <td className='px-4 py-2 align-middle'> @@ -498,9 +516,11 @@ const EnvironmentVariablesComponent = ( /> ) : ( <div className='space-y-1'> - <p className='font-medium text-sm'>{row.key || 'Untitled variable'}</p> + <p className='font-medium text-sm'>{row.key || environmentCopy.labels.untitledVariable}</p> {hasWorkspaceConflict && ( - <p className='text-destructive text-xs'>Overridden by workspace variable</p> + <p className='text-destructive text-xs'> + {environmentCopy.labels.overriddenByWorkspaceVariable} + </p> )} </div> )} @@ -542,7 +562,11 @@ const EnvironmentVariablesComponent = ( onClick={() => toggleReveal(row.id)} > {isRevealed ? <EyeOff className='h-4 w-4' /> : <Eye className='h-4 w-4' />} - <span className='sr-only'>{isRevealed ? 'Hide value' : 'Reveal value'}</span> + <span className='sr-only'> + {isRevealed + ? environmentCopy.labels.hideValue + : environmentCopy.labels.revealValue} + </span> </Button> <div className='min-w-0 flex-1 rounded-md bg-muted/70 px-3 py-2'> <code className='block truncate font-mono text-xs'>{displayValue}</code> @@ -558,14 +582,14 @@ const EnvironmentVariablesComponent = ( }} > {isCopied ? <Check className='h-4 w-4' /> : <Copy className='h-4 w-4' />} - <span className='sr-only'>Copy environment value</span> + <span className='sr-only'>{environmentCopy.labels.copyValue}</span> </Button> </div> )} </td> <td className='px-4 py-2 align-middle text-muted-foreground text-sm'> - {formatDateTime(row.updatedAt ?? row.createdAt)} + {formatDateTime(row.updatedAt ?? row.createdAt, locale)} </td> <td className='px-4 py-2 align-middle'> @@ -583,7 +607,7 @@ const EnvironmentVariablesComponent = ( }} > <Check className='h-4 w-4' /> - <span className='sr-only'>Save environment variable</span> + <span className='sr-only'>{environmentCopy.labels.save}</span> </Button> <Button type='button' @@ -593,7 +617,7 @@ const EnvironmentVariablesComponent = ( onClick={cancelEditing} > <X className='h-4 w-4' /> - <span className='sr-only'>Cancel editing</span> + <span className='sr-only'>{environmentCopy.labels.cancel}</span> </Button> </> ) : ( @@ -606,7 +630,7 @@ const EnvironmentVariablesComponent = ( onClick={() => startEditingRow(keyScope, row)} > <Pencil className='h-4 w-4' /> - <span className='sr-only'>Edit environment variable</span> + <span className='sr-only'>{environmentCopy.labels.edit}</span> </Button> <Button type='button' @@ -618,7 +642,7 @@ const EnvironmentVariablesComponent = ( }} > <Trash2 className='h-4 w-4' /> - <span className='sr-only'>Delete environment variable</span> + <span className='sr-only'>{environmentCopy.labels.delete}</span> </Button> </> )} @@ -644,25 +668,27 @@ const EnvironmentVariablesComponent = ( <tr> <th className='px-4 pt-2 pb-3 text-left font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Created At + {environmentCopy.headers.createdAt} </span> </th> <th className='px-4 pt-2 pb-3 text-left font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Variable + {environmentCopy.headers.variable} </span> </th> <th className='px-4 pt-2 pb-3 text-left font-medium'> - <span className='text-muted-foreground text-xs uppercase tracking-wide'>Value</span> + <span className='text-muted-foreground text-xs uppercase tracking-wide'> + {environmentCopy.headers.value} + </span> </th> <th className='px-4 pt-2 pb-3 text-left font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Updated At + {environmentCopy.headers.updatedAt} </span> </th> <th className='px-4 pt-2 pb-3 text-right font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Actions + {environmentCopy.headers.actions} </span> </th> </tr> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/environment/environment.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/environment/environment.tsx index 2d2324064..5127b2c7a 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/environment/environment.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/environment/environment.tsx @@ -3,6 +3,7 @@ import { useRef, useState } from 'react' import { Braces, Plus, Search } from 'lucide-react' import { useParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { Button, Input } from '@/components/ui' import { EnvironmentVariables, @@ -10,26 +11,32 @@ import { } from '@/app/workspace/[workspaceId]/environment/components/environment-variables' import { PrimaryButton } from '@/app/workspace/[workspaceId]/knowledge/components' import { GlobalNavbarHeader } from '@/global-navbar' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' export function WorkspaceEnvironmentPage() { const params = useParams<{ workspaceId: string }>() const workspaceId = params.workspaceId + const locale = useLocale() as LocaleCode + const environmentCopy = getPublicCopy(locale).workspace.environment const [searchTerm, setSearchTerm] = useState('') const [keyScope, setKeyScope] = useState<'workspace' | 'personal'>('workspace') const [isCardLoading, setIsCardLoading] = useState(true) const envVarRef = useRef<EnvironmentVariablesHandle>(null) + const scopeLabel = + keyScope === 'workspace' ? environmentCopy.scope.workspace : environmentCopy.scope.personal const headerLeft = ( <div className='flex w-full flex-1 items-center gap-3'> <div className='hidden items-center gap-2 sm:flex'> <Braces className='h-[18px] w-[18px] text-muted-foreground' /> - <span className='font-medium text-sm'>Environment</span> + <span className='font-medium text-sm'>{environmentCopy.title}</span> </div> <div className='flex w-full max-w-xl flex-1'> <div className='flex h-9 w-full items-center gap-2 rounded-lg border bg-background pr-2 pl-3'> <Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} /> <Input - placeholder='Search variables...' + placeholder={environmentCopy.searchPlaceholder} value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' @@ -52,7 +59,7 @@ export function WorkspaceEnvironmentPage() { }`} aria-pressed={keyScope === 'workspace'} > - Workspace + {environmentCopy.scope.workspace} </Button> <Button variant='ghost' @@ -65,7 +72,7 @@ export function WorkspaceEnvironmentPage() { }`} aria-pressed={keyScope === 'personal'} > - Personal + {environmentCopy.scope.personal} </Button> </div> ) @@ -76,7 +83,11 @@ export function WorkspaceEnvironmentPage() { disabled={isCardLoading || (keyScope === 'workspace' && !workspaceId)} > <Plus className='h-3.5 w-3.5' /> - <span>Create {keyScope === 'workspace' ? 'Workspace' : 'Personal'} Environment Variable</span> + <span> + {keyScope === 'workspace' + ? environmentCopy.create.workspace + : environmentCopy.create.personal} + </span> </PrimaryButton> ) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/files/files.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/files/files.tsx index edbb9b4ac..75a9f3af2 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/files/files.tsx @@ -3,6 +3,7 @@ import { useMemo, useRef, useState } from 'react' import { AlertCircle, Download, FileText, Search, Trash2 } from 'lucide-react' import { useParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { Alert, AlertDescription, @@ -33,10 +34,14 @@ import { } from '@/app/workspace/[workspaceId]/files/utils' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { GlobalNavbarHeader } from '@/global-navbar' export function WorkspaceFiles() { const params = useParams<{ workspaceId: string }>() + const locale = useLocale() as LocaleCode + const filesCopy = getPublicCopy(locale).workspace.files const workspaceId = params.workspaceId const userPermissions = useUserPermissionsContext() const fileInputRef = useRef<HTMLInputElement>(null) @@ -67,10 +72,13 @@ export function WorkspaceFiles() { const uploadButtonLabel = uploading && uploadProgress.total > 0 - ? `Uploading ${uploadProgress.completed}/${uploadProgress.total}...` + ? formatTemplate(filesCopy.upload.uploadingWithCount, { + completed: uploadProgress.completed, + total: uploadProgress.total, + }) : uploading - ? 'Uploading...' - : 'Upload File' + ? filesCopy.upload.uploading + : filesCopy.upload.button const handleUploadClick = () => { fileInputRef.current?.click() @@ -89,13 +97,13 @@ export function WorkspaceFiles() { <div className='flex w-full flex-1 items-center gap-3'> <div className='hidden items-center gap-2 sm:flex'> <FileText className='h-[18px] w-[18px] text-muted-foreground' /> - <span className='font-medium text-sm'>Files</span> + <span className='font-medium text-sm'>{filesCopy.title}</span> </div> <div className='flex w-full max-w-xl flex-1'> <div className='flex h-9 w-full items-center gap-2 rounded-lg border bg-background pr-2 pl-3'> <Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} /> <Input - placeholder='Search files...' + placeholder={filesCopy.searchPlaceholder} value={search} onChange={(event) => setSearch(event.target.value)} className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' @@ -181,23 +189,23 @@ export function WorkspaceFiles() { <thead> <tr> <th className='px-4 pt-2 pb-3 text-left font-medium'> - <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Name + <span className='text-muted-foreground text-xs uppercase tracking-wide'> + {filesCopy.headers.name} </span> </th> <th className='px-4 pt-2 pb-3 text-left font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Size + {filesCopy.headers.size} </span> </th> <th className='px-4 pt-2 pb-3 text-left font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Uploaded + {filesCopy.headers.uploaded} </span> </th> <th className='px-4 pt-2 pb-3 text-left font-medium'> <span className='text-muted-foreground text-xs uppercase tracking-wide'> - Actions + {filesCopy.headers.actions} </span> </th> </tr> @@ -246,14 +254,13 @@ export function WorkspaceFiles() { ) : files.length === 0 ? ( <tr> <td colSpan={4} className='px-4 py-12 text-center'> - <p className='font-medium text-lg'>No files uploaded yet</p> + <p className='font-medium text-lg'>{filesCopy.emptyState.title}</p> <p className='mt-2 text-muted-foreground'> - Upload PDFs, docs, spreadsheets, or slides to power your - workspace. + {filesCopy.emptyState.description} </p> {userPermissions.canEdit && ( <Button className='mt-6' onClick={handleUploadClick}> - Upload File + {filesCopy.emptyState.button} </Button> )} </td> @@ -261,9 +268,9 @@ export function WorkspaceFiles() { ) : filteredFiles.length === 0 ? ( <tr> <td colSpan={4} className='px-4 py-12 text-center'> - <p className='font-medium text-lg'>No files match your search</p> + <p className='font-medium text-lg'>{filesCopy.searchEmpty.title}</p> <p className='mt-2 text-muted-foreground'> - Try a different keyword or clear the search input. + {filesCopy.searchEmpty.description} </p> </td> </tr> @@ -301,8 +308,8 @@ export function WorkspaceFiles() { size='icon' onClick={() => downloadFile(file)} className='h-8 w-8' - title='Download' - aria-label={`Download ${file.name}`} + title={filesCopy.actions.download} + aria-label={`${filesCopy.actions.download} ${file.name}`} > <Download className='h-4 w-4 text-muted-foreground' /> </Button> @@ -312,8 +319,8 @@ export function WorkspaceFiles() { size='icon' onClick={() => setFilePendingDelete(file)} className='h-8 w-8 text-destructive hover:text-destructive' - title='Delete' - aria-label={`Delete ${file.name}`} + title={filesCopy.actions.delete} + aria-label={`${filesCopy.actions.delete} ${file.name}`} > <Trash2 className='h-4 w-4' /> </Button> @@ -345,14 +352,18 @@ export function WorkspaceFiles() { } }} > - <AlertDialogContent> + <AlertDialogContent> <AlertDialogHeader> - <AlertDialogTitle>Delete file?</AlertDialogTitle> + <AlertDialogTitle>{filesCopy.deleteDialog.title}</AlertDialogTitle> <AlertDialogDescription> {filePendingDelete - ? `Deleting "${filePendingDelete.name}" will permanently remove it from this workspace.` - : 'Deleting this file will permanently remove it from this workspace.'}{' '} - <span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span> + ? formatTemplate(filesCopy.deleteDialog.descriptionWithName, { + name: filePendingDelete.name, + }) + : filesCopy.deleteDialog.description}{' '} + <span className='text-red-500 dark:text-red-500'> + {filesCopy.deleteDialog.warning} + </span> </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter className='flex'> @@ -360,7 +371,7 @@ export function WorkspaceFiles() { className='h-9 w-full rounded-sm' disabled={Boolean(filePendingDelete) && deletingFileId === filePendingDelete?.id} > - Cancel + {filesCopy.deleteDialog.cancel} </AlertDialogCancel> <Button onClick={async () => { @@ -372,8 +383,8 @@ export function WorkspaceFiles() { className='h-9 w-full rounded-sm bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600' > {filePendingDelete && deletingFileId === filePendingDelete.id - ? 'Deleting...' - : 'Delete'} + ? filesCopy.deleteDialog.deleting + : filesCopy.deleteDialog.confirm} </Button> </AlertDialogFooter> </AlertDialogContent> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/integrations/integrations.tsx index 6f56faccd..c9b7649c4 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, ExternalLink, Search, Waypoints } from 'lucide-react' import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -16,12 +17,16 @@ import { import { createLogger } from '@/lib/logs/console/logger' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { cn } from '@/lib/utils' +import { localizeHref, type LocaleCode } from '@/i18n/utils' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' import { GlobalNavbarHeader } from '@/global-navbar' const logger = createLogger('Integrations') export function Integrations() { const router = useRouter() + const locale = useLocale() as LocaleCode + const integrationsCopy = getPublicCopy(locale).workspace.integrations const searchParams = useSearchParams() const params = useParams() const workspaceId = params.workspaceId as string @@ -122,14 +127,14 @@ export function Integrations() { refetch().catch((error) => logger.error('Failed to refresh services after OAuth', error)) // Clear the URL parameters - router.replace(`/workspace/${workspaceId}/integrations`) + router.replace(localizeHref(locale, `/workspace/${workspaceId}/integrations`)) } else if (error) { - const message = errorDescription || 'Account connection failed. Please try again.' + const message = errorDescription || integrationsCopy.errors.oauth logger.error('OAuth error:', { error, errorDescription }) setAuthError(message) - router.replace(`/workspace/${workspaceId}/integrations`) + router.replace(localizeHref(locale, `/workspace/${workspaceId}/integrations`)) } - }, [searchParams, router, workspaceId, refetch]) + }, [locale, refetch, router, searchParams, workspaceId]) // Handle connect button click const handleConnect = async (service: ServiceInfo) => { @@ -232,13 +237,13 @@ export function Integrations() { <div className='flex w-full flex-1 items-center gap-3'> <div className='hidden items-center gap-2 sm:flex'> <Waypoints className='h-[18px] w-[18px] text-muted-foreground' /> - <span className='font-medium text-sm'>Integrations</span> + <span className='font-medium text-sm'>{integrationsCopy.title}</span> </div> <div className='flex w-full max-w-xl flex-1'> <div className='flex h-9 w-full items-center gap-2 rounded-lg border bg-background pr-2 pl-3'> <Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} /> <Input - placeholder='Search integrations...' + placeholder={integrationsCopy.searchPlaceholder} value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' @@ -267,7 +272,7 @@ export function Integrations() { </div> <div className='ml-3'> <p className='font-medium text-green-800 text-sm'> - Account connected successfully! + {integrationsCopy.successMessage} </p> </div> </div> @@ -289,18 +294,19 @@ export function Integrations() { <ExternalLink className='h-4 w-4 text-muted-foreground' /> </div> <div className='flex flex-1 flex-col'> - <p className='text-muted-foreground'> - <span className='font-medium text-foreground'>Action Required:</span> Please - connect your account to enable the requested features. The required service is - highlighted below. - </p> + <p className='text-muted-foreground'> + <span className='font-medium text-foreground'> + {integrationsCopy.actionRequired.title} + </span>{' '} + {integrationsCopy.actionRequired.description} + </p> <Button variant='outline' size='sm' onClick={scrollToHighlightedService} className='mt-3 flex h-8 items-center gap-1.5 self-start border-primary/20 px-3 font-medium text-muted-foreground text-sm transition-colors hover:border-primary hover:bg-[var(--primary)]/10 hover:text-muted-foreground' > - <span>Go to service</span> + <span>{integrationsCopy.actionRequired.button}</span> <ChevronDown className='h-3.5 w-3.5' /> </Button> </div> @@ -337,7 +343,8 @@ export function Integrations() { ([providerKey, providerServices]) => ( <div key={providerKey} className='flex flex-col gap-2'> <Label className='font-normal text-muted-foreground text-xs uppercase'> - {OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'} + {OAUTH_PROVIDERS[providerKey]?.name || + integrationsCopy.otherServices} </Label> {providerServices.map((service) => ( <div @@ -402,7 +409,7 @@ export function Integrations() { 'cursor-not-allowed' )} > - Disconnect + {integrationsCopy.disconnect} </Button> ) : ( <Button @@ -416,7 +423,7 @@ export function Integrations() { 'cursor-not-allowed' )} > - Connect + {integrationsCopy.connect} </Button> )} </div> @@ -429,7 +436,7 @@ export function Integrations() { !searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && ( <div className='py-8 text-center text-muted-foreground text-sm'> - No connectible integrations are configured. + {integrationsCopy.emptyState.noConnectible} </div> )} @@ -437,7 +444,9 @@ export function Integrations() { {searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && ( <div className='py-8 text-center text-muted-foreground text-sm'> - No services found matching "{searchTerm}" + {formatTemplate(integrationsCopy.emptyState.noSearchMatches, { + query: searchTerm, + })} </div> )} </div> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx index e08c22a38..dc68886fa 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx @@ -2,11 +2,13 @@ import { Plus, Search } from 'lucide-react' import { useParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { ChunkTableSkeleton, KnowledgeHeader, PrimaryButton, } from '@/app/workspace/[workspaceId]/knowledge/components' +import { localizeHref, type LocaleCode } from '@/i18n/utils' interface DocumentLoadingProps { knowledgeBaseId: string @@ -20,18 +22,19 @@ export function DocumentLoading({ documentName, }: DocumentLoadingProps) { const params = useParams() + const locale = useLocale() as LocaleCode const workspaceId = params?.workspaceId as string const breadcrumbs = [ { id: 'knowledge-root', label: 'Knowledge', - href: `/workspace/${workspaceId}/knowledge`, + href: localizeHref(locale, `/workspace/${workspaceId}/knowledge`), }, { id: `knowledge-base-${knowledgeBaseId}`, label: knowledgeBaseName, - href: `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`, + href: localizeHref(locale, `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`), }, { id: `document-${knowledgeBaseId}-${documentName}`, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index a49c46fea..4f9a5b007 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -14,6 +14,7 @@ import { X, } from 'lucide-react' import { useParams, useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { Button, Checkbox, @@ -33,6 +34,7 @@ import { import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { KnowledgeHeader, KnowledgeTags, PrimaryButton } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { localizeHref, type LocaleCode } from '@/i18n/utils' import { useDocumentChunks } from '@/hooks/use-knowledge' import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store' @@ -68,6 +70,7 @@ export function Document({ updateDocument: updateDocumentInStore, } = useKnowledgeStore() const { workspaceId } = useParams() + const locale = useLocale() as LocaleCode const searchParams = useSearchParams() const currentPageFromURL = Number.parseInt(searchParams.get('page') || '1', 10) const userPermissions = useUserPermissionsContext() @@ -472,10 +475,10 @@ export function Document({ ) const breadcrumbs = [ - { label: 'Knowledge', href: `/workspace/${workspaceId}/knowledge` }, + { label: 'Knowledge', href: localizeHref(locale, `/workspace/${workspaceId}/knowledge`) }, { label: effectiveKnowledgeBaseName, - href: `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`, + href: localizeHref(locale, `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`), }, { label: effectiveDocumentName }, ] @@ -833,10 +836,10 @@ export function Document({ if (combinedError) { const errorBreadcrumbs = [ - { label: 'Knowledge', href: `/workspace/${workspaceId}/knowledge` }, + { label: 'Knowledge', href: localizeHref(locale, `/workspace/${workspaceId}/knowledge`) }, { label: effectiveKnowledgeBaseName, - href: `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`, + href: localizeHref(locale, `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`), }, { label: 'Error' }, ] diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index eb37d89c0..1eb1a8149 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -19,6 +19,7 @@ import { X, } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { SearchHighlight } from '@/components/ui/search-highlight' @@ -39,6 +40,7 @@ import { SearchInput, } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { localizeHref, type LocaleCode } from '@/i18n/utils' import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/use-knowledge' import { type DocumentData } from '@/stores/knowledge/store' @@ -123,6 +125,7 @@ export function KnowledgeBase({ const userPermissions = useUserPermissionsContext() const params = useParams() const workspaceId = params.workspaceId as string + const locale = useLocale() as LocaleCode const [searchQuery, setSearchQuery] = useState('') @@ -459,7 +462,9 @@ export function KnowledgeBase({ kbName: knowledgeBaseName, // Use the instantly available name docName: document?.filename || 'Document', }) - router.push(`/workspace/${workspaceId}/knowledge/${id}/${docId}?${urlParams.toString()}`) + router.push( + localizeHref(locale, `/workspace/${workspaceId}/knowledge/${id}/${docId}?${urlParams.toString()}`) + ) } const handleAddDocuments = () => { @@ -619,7 +624,7 @@ export function KnowledgeBase({ { id: 'knowledge-root', label: 'Knowledge', - href: `/workspace/${workspaceId}/knowledge`, + href: localizeHref(locale, `/workspace/${workspaceId}/knowledge`), }, { id: `knowledge-base-${id}`, @@ -669,7 +674,7 @@ export function KnowledgeBase({ { id: 'knowledge-root', label: 'Knowledge', - href: `/workspace/${workspaceId}/knowledge`, + href: localizeHref(locale, `/workspace/${workspaceId}/knowledge`), }, { id: 'error', diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx index d8f0e3f99..74982f9ff 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx @@ -2,12 +2,14 @@ import { Plus } from 'lucide-react' import { useParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { DocumentTableSkeleton, KnowledgeHeader, PrimaryButton, SearchInput, } from '@/app/workspace/[workspaceId]/knowledge/components' +import { localizeHref, type LocaleCode } from '@/i18n/utils' interface KnowledgeBaseLoadingProps { knowledgeBaseName: string @@ -15,13 +17,14 @@ interface KnowledgeBaseLoadingProps { export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps) { const params = useParams() + const locale = useLocale() as LocaleCode const workspaceId = params?.workspaceId as string const breadcrumbs = [ { id: 'knowledge-root', label: 'Knowledge', - href: `/workspace/${workspaceId}/knowledge`, + href: localizeHref(locale, `/workspace/${workspaceId}/knowledge`), }, { id: 'knowledge-base-loading', diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx index ed943786d..9b8d0e71e 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx @@ -4,6 +4,7 @@ import { useState, type KeyboardEvent, type MouseEvent, type SyntheticEvent } fr import { Check, Copy, LibraryBig, Loader2, Trash2 } from 'lucide-react' import Link from 'next/link' import { useParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { AlertDialog, AlertDialogAction, @@ -15,6 +16,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { WorkspaceSelector } from '@/app/workspace/[workspaceId]/knowledge/components/workspace-selector/workspace-selector' +import { localizeHref, type LocaleCode } from '@/i18n/utils' import { useKnowledgeStore } from '@/stores/knowledge/store' interface BaseOverviewProps { @@ -86,13 +88,17 @@ export function BaseOverview({ const [isDeleting, setIsDeleting] = useState(false) const params = useParams() const workspaceSlug = params?.workspaceId as string + const locale = useLocale() as LocaleCode const { removeKnowledgeBase } = useKnowledgeStore() const canManage = canEdit === true && !!id const searchParams = new URLSearchParams({ kbName: title, }) - const href = `/workspace/${workspaceSlug}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}` + const href = localizeHref( + locale, + `/workspace/${workspaceSlug}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}` + ) const handleCopy = async (e: MouseEvent) => { e.preventDefault() diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/workspace-selector/workspace-selector.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/workspace-selector/workspace-selector.tsx index 31502114e..000965260 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/workspace-selector/workspace-selector.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/workspace-selector/workspace-selector.tsx @@ -10,12 +10,14 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useLocale } from 'next-intl' import { createLogger } from '@/lib/logs/console/logger' import { commandListClass, dropdownContentClass, filterButtonClass, } from '@/app/workspace/[workspaceId]/knowledge/components/shared' +import { buildLocaleRequestHeaders, type LocaleCode } from '@/i18n/utils' import { useKnowledgeStore } from '@/stores/knowledge/store' const logger = createLogger('WorkspaceSelector') @@ -41,6 +43,7 @@ export function WorkspaceSelector({ disabled = false, variant = 'default', }: WorkspaceSelectorProps) { + const locale = useLocale() as LocaleCode const { updateKnowledgeBase } = useKnowledgeStore() const [workspaces, setWorkspaces] = useState<Workspace[]>([]) const [isLoading, setIsLoading] = useState(false) @@ -52,7 +55,9 @@ export function WorkspaceSelector({ try { setIsLoading(true) - const response = await fetch('/api/workspaces') + const response = await fetch('/api/workspaces', { + headers: buildLocaleRequestHeaders(locale), + }) if (!response.ok) { throw new Error('Failed to fetch workspaces') } @@ -77,7 +82,7 @@ export function WorkspaceSelector({ } fetchWorkspaces() - }, []) + }, [locale]) const handleWorkspaceChange = async (workspaceId: string | null) => { if (isUpdating || disabled) return diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 1ce34c367..b3f8c7487 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { Check, ChevronDown, LibraryBig, Plus } from 'lucide-react' -import { useParams } from 'next/navigation' +import { useParams, usePathname } from 'next/navigation' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -34,6 +34,8 @@ import { } from '@/app/workspace/[workspaceId]/knowledge/utils/sort' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { GlobalNavbarHeader } from '@/global-navbar' +import { getPublicCopy, formatTemplate } from '@/i18n/public-copy' +import { stripLocaleFromPathname } from '@/i18n/utils' import { useKnowledgeBasesList } from '@/hooks/use-knowledge' import type { KnowledgeBaseData } from '@/stores/knowledge/store' @@ -43,7 +45,10 @@ interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { export function Knowledge() { const params = useParams() + const pathname = usePathname() const workspaceId = params.workspaceId as string + const locale = stripLocaleFromPathname(pathname ?? '/').locale + const knowledgeCopy = getPublicCopy(locale).workspace.knowledge const { knowledgeBases, isLoading, error, addKnowledgeBase, refreshList } = useKnowledgeBasesList(workspaceId) @@ -56,8 +61,16 @@ export function Knowledge() { const [sortOrder, setSortOrder] = useState<SortOrder>('desc') const currentSortValue = `${sortBy}-${sortOrder}` - const currentSortLabel = - SORT_OPTIONS.find((opt) => opt.value === currentSortValue)?.label || 'Last Updated' + const sortLabels: Record<string, string> = { + 'updatedAt-desc': knowledgeCopy.sort.lastUpdated, + 'createdAt-desc': knowledgeCopy.sort.newestFirst, + 'createdAt-asc': knowledgeCopy.sort.oldestFirst, + 'name-asc': knowledgeCopy.sort.nameAsc, + 'name-desc': knowledgeCopy.sort.nameDesc, + 'docCount-desc': knowledgeCopy.sort.mostDocuments, + 'docCount-asc': knowledgeCopy.sort.leastDocuments, + } + const currentSortLabel = sortLabels[currentSortValue] || knowledgeCopy.sort.lastUpdated const handleSortChange = (value: string) => { const [field, order] = value.split('-') as [SortOption, SortOrder] @@ -92,13 +105,13 @@ export function Knowledge() { <div className='flex w-full flex-1 items-center gap-3'> <div className='hidden items-center gap-2 sm:flex'> <LibraryBig className='h-[18px] w-[18px] text-muted-foreground' /> - <span className='font-medium text-sm'>Knowledge</span> + <span className='font-medium text-sm'>{knowledgeCopy.title}</span> </div> <div className='flex w-full max-w-xl flex-1'> <SearchInput value={searchQuery} onChange={setSearchQuery} - placeholder='Search knowledge bases...' + placeholder={knowledgeCopy.searchPlaceholder} className='w-full' /> </div> @@ -128,7 +141,7 @@ export function Knowledge() { onSelect={() => handleSortChange(option.value)} 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' > - <span>{option.label}</span> + <span>{sortLabels[option.value] || option.label}</span> {currentSortValue === option.value && ( <Check className='h-4 w-4 text-muted-foreground' /> )} @@ -147,11 +160,11 @@ export function Knowledge() { disabled={!canManageKnowledgeBases} > <Plus className='h-3.5 w-3.5' /> - <span>Create</span> + <span>{knowledgeCopy.actions.create}</span> </PrimaryButton> </TooltipTrigger> {userPermissions.canEdit !== true && ( - <TooltipContent>Write permission required to create knowledge bases</TooltipContent> + <TooltipContent>{knowledgeCopy.actions.createTooltip}</TooltipContent> )} </Tooltip> </div> @@ -168,12 +181,14 @@ export function Knowledge() { {/* Error State */} {error && ( <div className='mb-4 rounded-md border border-red-200 bg-red-50 p-4'> - <p className='text-red-800 text-sm'>Error loading knowledge bases: {error}</p> + <p className='text-red-800 text-sm'> + {formatTemplate(knowledgeCopy.errors.load, { error })} + </p> <button onClick={handleRetry} className='mt-2 text-red-600 text-sm underline hover:text-red-800' > - Try again + {knowledgeCopy.errors.retry} </button> </div> )} @@ -186,16 +201,16 @@ export function Knowledge() { {filteredAndSortedKnowledgeBases.length === 0 ? ( knowledgeBases.length === 0 ? ( <EmptyStateCard - title='Create your first knowledge base' + title={knowledgeCopy.emptyState.createFirst} description={ userPermissions.canEdit === true - ? 'Upload your documents to create a knowledge base for your agents.' - : 'Knowledge bases will appear here. Contact an admin to create knowledge bases.' + ? knowledgeCopy.emptyState.withEditPermission + : knowledgeCopy.emptyState.withoutEditPermission } buttonText={ userPermissions.canEdit === true - ? 'Create Knowledge Base' - : 'Contact Admin' + ? knowledgeCopy.emptyState.buttonCreate + : knowledgeCopy.emptyState.buttonContactAdmin } onClick={ userPermissions.canEdit === true @@ -207,7 +222,7 @@ export function Knowledge() { ) : ( <div className='col-span-full py-12 text-center'> <p className='text-muted-foreground'> - No knowledge bases match your search. + {knowledgeCopy.emptyState.noMatches} </p> </div> ) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/layout.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/layout.test.tsx index 16cf1f25c..672ee6e85 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/layout.test.tsx @@ -66,6 +66,31 @@ describe('Workspace layout access guard', () => { expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() }) + it('keeps locale-prefixed login redirects localized for non-default locales', async () => { + mockGetSession.mockResolvedValue(null) + mockHeaders.mockResolvedValue( + new Headers([ + ['x-next-intl-locale', 'zh-CN'], + ['x-auth-callback-url', '/zh/workspace/ws-1/dashboard?layoutId=layout-1'], + ]) + ) + + const WorkspaceLayout = (await import('./layout')).default + + await expect( + WorkspaceLayout({ + children: <div>workspace</div>, + params: Promise.resolve({ workspaceId: 'ws-1' }), + }) + ).rejects.toThrow( + 'redirect:/zh/login?reauth=1&callbackUrl=%2Fzh%2Fworkspace%2Fws-1%2Fdashboard%3FlayoutId%3Dlayout-1' + ) + + expect(mockRedirect).toHaveBeenCalledWith( + '/zh/login?reauth=1&callbackUrl=%2Fzh%2Fworkspace%2Fws-1%2Fdashboard%3FlayoutId%3Dlayout-1' + ) + }) + it('redirects to /workspace when the user cannot access the workspace', async () => { mockGetSession.mockResolvedValue({ user: { diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/layout.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/layout.tsx index d0a22ae63..8c347ac17 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/layout.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/layout.tsx @@ -3,6 +3,7 @@ import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import Providers from '@/app/workspace/[workspaceId]/providers/providers' +import { defaultLocale, isLocaleCode, localizeHref, type LocaleCode } from '@/i18n/utils' export default async function WorkspaceLayout({ children, @@ -13,18 +14,21 @@ export default async function WorkspaceLayout({ }) { const { workspaceId } = await params const requestHeaders = await headers() + const localeHeader = requestHeaders.get('x-next-intl-locale') + const resolvedLocale = localeHeader ?? '' + const locale: LocaleCode = isLocaleCode(resolvedLocale) ? resolvedLocale : defaultLocale const session = await getSession(requestHeaders, { disableCookieCache: true }) if (!session?.user?.id) { const callbackTarget = - requestHeaders.get('x-auth-callback-url') || `/workspace/${workspaceId}/dashboard` - redirect(`/login?reauth=1&callbackUrl=${encodeURIComponent(callbackTarget)}`) + requestHeaders.get('x-auth-callback-url') || localizeHref(locale, `/workspace/${workspaceId}/dashboard`) + redirect(localizeHref(locale, `/login?reauth=1&callbackUrl=${encodeURIComponent(callbackTarget)}`)) } const access = await checkWorkspaceAccess(workspaceId, session.user.id) if (!access.exists || !access.hasAccess) { - redirect('/workspace') + redirect(localizeHref(locale, '/workspace')) } return ( diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/kpis.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/kpis.tsx index ff3f68f00..238edfe85 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/kpis.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/kpis.tsx @@ -1,3 +1,9 @@ +'use client' + +import { useLocale } from 'next-intl' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' + export interface AggregateMetrics { totalExecutions: number successfulExecutions: number @@ -7,28 +13,30 @@ export interface AggregateMetrics { } export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.dashboard.metrics return ( <div className='mb-2 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'> <div className='rounded-lg border bg-card p-4 shadow-sm'> - <div className='text-muted-foreground text-xs'>Total executions</div> + <div className='text-muted-foreground text-xs'>{copy.totalExecutions}</div> <div className='mt-1 font-[440] text-[22px] leading-6'> {aggregate.totalExecutions.toLocaleString()} </div> </div> <div className='rounded-lg border bg-card p-4 shadow-sm'> - <div className='text-muted-foreground text-xs'>Success rate</div> + <div className='text-muted-foreground text-xs'>{copy.successRate}</div> <div className='mt-1 font-[440] text-[22px] leading-6'> {aggregate.successRate.toFixed(1)}% </div> </div> <div className='rounded-lg border bg-card p-4 shadow-sm'> - <div className='text-muted-foreground text-xs'>Failed executions</div> + <div className='text-muted-foreground text-xs'>{copy.failedExecutions}</div> <div className='mt-1 font-[440] text-[22px] leading-6'> {aggregate.failedExecutions.toLocaleString()} </div> </div> <div className='rounded-lg border bg-card p-4 shadow-sm'> - <div className='text-muted-foreground text-xs'>Active workflows</div> + <div className='text-muted-foreground text-xs'>{copy.activeWorkflows}</div> <div className='mt-1 font-[440] text-[22px] leading-6'>{aggregate.activeWorkflows}</div> </div> </div> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart.tsx index 5f4ff5cd6..a472da8b3 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { TooltipProvider } from '@/components/ui/tooltip' import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils' +import { formatTemplate } from '@/i18n/public-copy' export interface LineChartPoint { timestamp: string @@ -21,12 +22,19 @@ export function LineChart({ color, unit, series, + locale, + copy, }: { data: LineChartPoint[] label: string color: string unit?: string series?: LineChartMultiSeries[] + locale: string + copy: { + noData: string + toggleSeries: string + } }) { const containerRef = useRef<HTMLDivElement | null>(null) const [containerWidth, setContainerWidth] = useState<number>(420) @@ -72,7 +80,7 @@ export function LineChart({ className='flex items-center justify-center rounded-lg border bg-card p-4' style={{ width, height }} > - <p className='text-muted-foreground text-sm'>No data</p> + <p className='text-muted-foreground text-sm'>{copy.noData}</p> </div> ) } @@ -152,12 +160,12 @@ export function LineChart({ const getCompactDateLabel = (timestamp?: string) => { if (!timestamp) return '' try { - const f = formatDate(timestamp) + const f = formatDate(timestamp, locale) return `${f.compactDate} · ${f.compactTime}` } catch (e) { const d = new Date(timestamp) if (Number.isNaN(d.getTime())) return '' - return d.toLocaleString('en-US', { + return d.toLocaleString(locale, { month: 'short', day: 'numeric', hour: '2-digit', @@ -186,7 +194,7 @@ export function LineChart({ key={`legend-${s.id}`} type='button' aria-pressed={activeSeriesId === s.id} - aria-label={`Toggle ${s.label}`} + aria-label={formatTemplate(copy.toggleSeries, { label: s.label })} className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px]' style={{ color: s.color, @@ -425,16 +433,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) => { @@ -462,7 +470,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]/logs/components/dashboard/components/logs-filters/logs-filters.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/logs-filters/logs-filters.tsx index 8c9f019b7..4e95ced9a 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/logs-filters/logs-filters.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/logs-filters/logs-filters.tsx @@ -1,5 +1,6 @@ 'use client' +import { useLocale } from 'next-intl' import { ScrollArea } from '@/components/ui/scroll-area' import FilterSection from '@/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/filter-section' import FolderFilter from '@/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/folder' @@ -7,17 +8,21 @@ import Level from '@/app/workspace/[workspaceId]/logs/components/logs-toolbar/co import Timeline from '@/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/timeline' import Trigger from '@/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/trigger' import Workflow from '@/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/workflow' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { useFilterStore } from '@/stores/logs/filters/store' export function LogsFilters() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.dashboard.filters const viewMode = useFilterStore((state) => state.viewMode) const sections = [ - { key: 'level', title: 'Level', component: <Level />, showInDashboard: false }, - { key: 'workflow', title: 'Workflow', component: <Workflow />, showInDashboard: true }, - { key: 'folder', title: 'Folder', component: <FolderFilter />, showInDashboard: true }, - { key: 'trigger', title: 'Trigger', component: <Trigger />, showInDashboard: true }, - { key: 'timeline', title: 'Timeline', component: <Timeline />, showInDashboard: true }, + { key: 'level', title: copy.level, component: <Level />, showInDashboard: false }, + { key: 'workflow', title: copy.workflow, component: <Workflow />, showInDashboard: true }, + { key: 'folder', title: copy.folder, component: <FolderFilter />, showInDashboard: true }, + { key: 'trigger', title: copy.trigger, component: <Trigger />, showInDashboard: true }, + { key: 'timeline', title: copy.timeline, component: <Timeline />, showInDashboard: true }, ] const filteredSections = @@ -28,7 +33,12 @@ export function LogsFilters() { <ScrollArea className='h-full' hideScrollbar={true}> <div className='space-y-4 px-3 py-3'> {filteredSections.map((section) => ( - <FilterSection key={section.key} title={section.title} content={section.component} /> + <FilterSection + key={section.key} + title={section.title} + content={section.component} + emptyMessage={formatTemplate(copy.filterOptionsPlaceholder, { title: section.title })} + /> ))} </div> </ScrollArea> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar.tsx index d7bc7b01c..969352662 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar.tsx @@ -1,4 +1,9 @@ +'use client' + import { memo, useMemo, useState } from 'react' +import { useLocale } from 'next-intl' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' export interface StatusBarSegment { successRate: number @@ -28,6 +33,8 @@ export function StatusBar({ segmentDurationMs: number preferBelow?: boolean }) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.dashboard.workflows const [hoverIndex, setHoverIndex] = useState<number | null>(null) const labels = useMemo(() => { @@ -36,14 +43,17 @@ export function StatusBar({ 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' })}` + : `${start.toLocaleString(locale, { month: 'short', day: 'numeric', hour: 'numeric' })} – ${end.toLocaleString(locale, { hour: 'numeric', minute: '2-digit' })}` return { rangeLabel, successLabel: `${segment.successRate.toFixed(1)}%`, - countsLabel: `${segment.successfulExecutions ?? 0}/${segment.totalExecutions ?? 0} succeeded`, + countsLabel: formatTemplate(copy.succeeded, { + success: segment.successfulExecutions ?? 0, + total: segment.totalExecutions ?? 0, + }), } }) - }, [segments, segmentDurationMs]) + }, [segments, segmentDurationMs, locale, copy.succeeded]) return ( <div className='relative'> @@ -72,7 +82,7 @@ export function StatusBar({ key={i} 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={formatTemplate(copy.segment, { index: i + 1 })} onMouseEnter={() => setHoverIndex(i)} onMouseDown={(e) => { e.preventDefault() diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/workflow-details.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/workflow-details.tsx index 382eca688..d3b10596f 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/workflow-details.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/workflow-details.tsx @@ -1,12 +1,15 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Info, Loader2 } from 'lucide-react' import { useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' import { cn } from '@/lib/utils' import LineChart, { type LineChartPoint, } from '@/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart' import { getTriggerColor } from '@/app/workspace/[workspaceId]/logs/components/dashboard/utils' import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { localizeHref, type LocaleCode } from '@/i18n/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export interface ExecutionLogItem { @@ -68,6 +71,9 @@ export function WorkflowDetails({ isLoadingMore?: boolean }) { const router = useRouter() + const locale = useLocale() as LocaleCode + const dashboardCopy = getPublicCopy(locale).workspace.logs.dashboard + const copy = dashboardCopy.workflows const { workflows } = useWorkflowRegistry() const workflowColor = useMemo( () => workflows[expandedWorkflowId]?.color || '#3972F6', @@ -121,7 +127,7 @@ export function WorkflowDetails({ <div className='flex items-center justify-between'> <div className='flex items-center gap-2'> <button - onClick={() => router.push(`/workspace/${workspaceId}/dashboard`)} + onClick={() => router.push(localizeHref(locale, `/workspace/${workspaceId}/dashboard`))} className='group inline-flex items-center gap-2 text-left' > <span @@ -135,15 +141,15 @@ export function WorkflowDetails({ </div> <div className='flex items-center gap-2'> <div className='inline-flex h-7 items-center gap-2 rounded-md border px-2.5'> - <span className='text-[11px] text-muted-foreground'>Executions</span> + <span className='text-[11px] text-muted-foreground'>{copy.executions}</span> <span className='font-[500] text-sm leading-none'>{overview.total}</span> </div> <div className='inline-flex h-7 items-center gap-2 rounded-md border px-2.5'> - <span className='text-[11px] text-muted-foreground'>Success</span> + <span className='text-[11px] text-muted-foreground'>{copy.success}</span> <span className='font-[500] text-sm leading-none'>{overview.rate.toFixed(1)}%</span> </div> <div className='inline-flex h-7 items-center gap-2 rounded-md border px-2.5'> - <span className='text-[11px] text-muted-foreground'>Failures</span> + <span className='text-[11px] text-muted-foreground'>{copy.failures}</span> <span className='font-[500] text-sm leading-none'>{overview.failures}</span> </div> </div> @@ -159,34 +165,39 @@ export function WorkflowDetails({ const tsObj = selectedSegment?.timestamp ? new Date(selectedSegment.timestamp) : null - const tsLabel = - tsObj && !Number.isNaN(tsObj.getTime()) - ? tsObj.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - }) - : 'Selected segment' + const tsLabel = + tsObj && !Number.isNaN(tsObj.getTime()) + ? tsObj.toLocaleString(locale, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) + : copy.selectedSegment return ( <div className='mb-4 flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2 text-[13px] text-foreground'> <div className='flex items-center gap-2'> <div className='h-1.5 w-1.5 rounded-full bg-primary ring-2 ring-primary/30' /> <span className='font-medium'> - Filtered to {tsLabel} + {formatTemplate(copy.filteredTo, { timestamp: tsLabel })} {selectedSegmentIndex.length > 1 - ? ` (+${selectedSegmentIndex.length - 1} more segment${selectedSegmentIndex.length - 1 > 1 ? 's' : ''})` + ? formatTemplate(copy.selectedRangeMore, { + count: selectedSegmentIndex.length - 1, + plural: selectedSegmentIndex.length - 1 > 1 ? 's' : '', + }) : ''} - — {selectedSegment.totalExecutions} execution - {selectedSegment.totalExecutions !== 1 ? 's' : ''} + {formatTemplate(copy.selectedRangeExecutions, { + count: selectedSegment.totalExecutions, + plural: selectedSegment.totalExecutions !== 1 ? 's' : '', + })} </span> </div> <button onClick={clearSegmentSelection} className='rounded px-2 py-1 text-foreground text-xs hover:bg-card focus:outline-none focus:ring-2 focus:ring-primary/40' > - Clear filter + {copy.clearFilter} </button> </div> ) @@ -201,16 +212,20 @@ export function WorkflowDetails({ <div className={`mb-3 grid grid-cols-1 gap-3 ${gridCols}`}> <LineChart data={details.errorRates} - label='Error Rate' + label={copy.errorRate} color='#ef4444' unit='%' + locale={locale} + copy={dashboardCopy.chart} /> {hasDuration && ( <LineChart data={details.durations!} - label='Duration' + label={copy.duration} color='#3b82f6' unit='ms' + locale={locale} + copy={dashboardCopy.chart} series={ [ details.durationP50 @@ -244,16 +259,27 @@ export function WorkflowDetails({ )} <LineChart data={details.executionCounts} - label='Executions' + label={copy.executions} color='#10b981' unit='execs' + locale={locale} + copy={dashboardCopy.chart} /> {(() => { const failures = details.errorRates.map((e, i) => ({ timestamp: e.timestamp, value: ((e.value || 0) / 100) * (details.executionCounts[i]?.value || 0), })) - return <LineChart data={failures} label='Failures' color='#f59e0b' unit='' /> + return ( + <LineChart + data={failures} + label={copy.failures} + color='#f59e0b' + unit='' + locale={locale} + copy={dashboardCopy.chart} + /> + ) })()} </div> ) @@ -265,25 +291,25 @@ export function WorkflowDetails({ <div className='border-border border-b'> <div className='grid min-w-[980px] grid-cols-[140px_90px_90px_90px_180px_1fr_100px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4'> <div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'> - Time + {copy.columns.time} </div> <div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'> - Status + {copy.columns.status} </div> <div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'> - Trigger + {copy.columns.trigger} </div> <div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'> - Cost + {copy.columns.cost} </div> <div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'> - Workflow + {copy.columns.workflow} </div> <div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'> - Output + {copy.columns.output} </div> <div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'> - Duration + {copy.columns.duration} </div> </div> </div> @@ -300,9 +326,7 @@ export function WorkflowDetails({ <div className='flex h-full items-center justify-center py-8'> <div className='flex items-center gap-2 text-muted-foreground'> <Info className='h-5 w-5' /> - <span className='text-sm'> - No executions found in this time segment - </span> + <span className='text-sm'>{copy.noExecutions}</span> </div> </div> ) @@ -312,7 +336,7 @@ export function WorkflowDetails({ const logDate = log?.startedAt ? new Date(log.startedAt) : null const formattedDate = logDate && !Number.isNaN(logDate.getTime()) - ? formatDate(logDate.toISOString()) + ? formatDate(logDate.toISOString(), locale) : ({ compactDate: '—', compactTime: '' } as any) const outputsStr = log.outputs ? JSON.stringify(log.outputs) : '—' const errorStr = log.errorMessage || '' @@ -444,10 +468,10 @@ export function WorkflowDetails({ {isLoadingMore ? ( <> <Loader2 className='h-4 w-4 animate-spin' /> - <span className='text-sm'>Loading more…</span> + <span className='text-sm'>{copy.loadingMore}</span> </> ) : ( - <span className='text-sm'>Scroll to load more</span> + <span className='text-sm'>{copy.scrollToLoadMore}</span> )} </div> </div> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list.tsx index 9efd27991..7a4d2a6e9 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list.tsx @@ -1,4 +1,9 @@ +'use client' + import { memo, useMemo } from 'react' +import { useLocale } from 'next-intl' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { ScrollArea } from '@/components/ui/scroll-area' import StatusBar, { type StatusBarSegment, @@ -36,24 +41,39 @@ export function WorkflowsList({ searchQuery: string segmentDurationMs: number }) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.dashboard.workflows const { workflows } = useWorkflowRegistry() const segmentsCount = filteredExecutions[0]?.segments?.length || 120 const durationLabel = useMemo(() => { const segMs = Math.max(1, Math.floor(segmentDurationMs || 0)) const days = Math.round(segMs / (24 * 60 * 60 * 1000)) - if (days >= 1) return `${days} day${days !== 1 ? 's' : ''}` + if (days >= 1) { + return formatTemplate(copy.durationDay, { + count: days, + plural: days !== 1 ? 's' : '', + }) + } const hours = Math.round(segMs / (60 * 60 * 1000)) - if (hours >= 1) return `${hours} hour${hours !== 1 ? 's' : ''}` + if (hours >= 1) { + return formatTemplate(copy.durationHour, { + count: hours, + plural: hours !== 1 ? 's' : '', + }) + } const mins = Math.max(1, Math.round(segMs / (60 * 1000))) - return `${mins} minute${mins !== 1 ? 's' : ''}` - }, [segmentDurationMs]) + return formatTemplate(copy.durationMinute, { + count: mins, + plural: mins !== 1 ? 's' : '', + }) + }, [segmentDurationMs, copy.durationDay, copy.durationHour, copy.durationMinute]) // Date axis above the status bars intentionally removed for a cleaner, denser layout function DynamicLegend() { return ( <p className='mt-0.5 text-[11px] text-muted-foreground'> - Each cell ≈ {durationLabel} of the selected range. Click a cell to filter details. + {formatTemplate(copy.legend, { duration: durationLabel })} </p> ) } @@ -65,13 +85,15 @@ export function WorkflowsList({ <div className='flex-shrink-0 border-b bg-muted/30 px-4 py-2'> <div className='flex items-center justify-between'> <div> - <h3 className='font-[480] text-sm'>Workflows</h3> + <h3 className='font-[480] text-sm'>{copy.title}</h3> <DynamicLegend /> </div> <span className='text-muted-foreground text-xs'> - {filteredExecutions.length} workflow - {filteredExecutions.length !== 1 ? 's' : ''} - {searchQuery && ` (filtered from ${executions.length})`} + {formatTemplate( + filteredExecutions.length === 1 ? copy.count : copy.countPlural, + { count: filteredExecutions.length } + )} + {searchQuery && formatTemplate(copy.filteredFrom, { count: executions.length })} </span> </div> </div> @@ -80,7 +102,7 @@ export function WorkflowsList({ <div className='space-y-1 p-3'> {filteredExecutions.length === 0 ? ( <div className='py-8 text-center text-muted-foreground text-sm'> - No workflows found matching "{searchQuery}" + {formatTemplate(copy.noMatches, { query: searchQuery })} </div> ) : ( filteredExecutions.map((workflow, idx) => { diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx index c75f0ae6f..2d5e67472 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Filter, Loader2, Scroll, RefreshCw, Search } from 'lucide-react' import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -19,6 +20,8 @@ import LevelFilter from '@/app/workspace/[workspaceId]/logs/components/logs-tool import { mapToExecutionLog, mapToExecutionLogAlt } from '@/app/workspace/[workspaceId]/logs/utils' import { GlobalNavbarHeader } from '@/global-navbar' import { formatCost } from '@/providers/ai/utils' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { useFilterStore } from '@/stores/logs/filters/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { LogsFilters } from '@/app/workspace/[workspaceId]/logs/components/dashboard/components/logs-filters/logs-filters' @@ -80,6 +83,8 @@ export function Dashboard() { const workspaceId = params.workspaceId as string const router = useRouter() const searchParams = useSearchParams() + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs const getTimeFilterFromRange = (range: string): TimeFilter => { switch (range) { @@ -269,7 +274,7 @@ export function Dashboard() { ) if (!response.ok) { - throw new Error('Failed to fetch execution history') + throw new Error(copy.dashboard.failedToFetchExecutionHistory) } const data = await response.json() @@ -701,7 +706,7 @@ export function Dashboard() { 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' })}` + 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 shiftTimeWindow = (direction: 'back' | 'forward') => { @@ -764,14 +769,14 @@ export function Dashboard() { <div className='flex w-full flex-1 items-center gap-3'> <div className='hidden items-center gap-2 sm:flex'> <Scroll className='h-[18px] w-[18px] text-muted-foreground' /> - <span className='font-medium text-sm'>Logs</span> + <span className='font-medium text-sm'>{copy.title.logs}</span> </div> <div className='relative flex-1'> <Search className='-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground' /> <Input value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} - placeholder='Search workflows...' + placeholder={copy.dashboard.searchPlaceholder} className='h-full w-full rounded-md border bg-background pr-3 pl-10 text-sm' /> </div> @@ -797,7 +802,7 @@ export function Dashboard() { )} aria-pressed={live} > - Live + {copy.live} </Button> </div> @@ -814,7 +819,7 @@ export function Dashboard() { )} aria-pressed={viewMode === 'logs'} > - Logs + {copy.title.logs} </Button> <Button variant='ghost' @@ -828,7 +833,7 @@ export function Dashboard() { )} aria-pressed={viewMode === 'monitors'} > - Monitors + {copy.title.monitors} </Button> <Button variant='ghost' @@ -842,7 +847,7 @@ export function Dashboard() { )} aria-pressed={viewMode === 'dashboard'} > - Dashboard + {copy.title.dashboard} </Button> </div> </div> @@ -864,10 +869,12 @@ export function Dashboard() { ) : ( <RefreshCw className='h-5 w-5' /> )} - <span className='sr-only'>Refresh</span> + <span className='sr-only'>{copy.dashboard.refresh}</span> </Button> </TooltipTrigger> - <TooltipContent>{isRefetching ? 'Refreshing...' : 'Refresh'}</TooltipContent> + <TooltipContent> + {isRefetching ? copy.dashboard.refreshing : copy.dashboard.refresh} + </TooltipContent> </Tooltip> </div> ) @@ -892,21 +899,21 @@ export function Dashboard() { <div className='flex flex-1 items-center justify-center'> <div className='flex items-center gap-2 text-muted-foreground'> <Loader2 className='h-5 w-5 animate-spin' /> - <span>Loading execution history...</span> + <span>{copy.dashboard.loadingExecutionHistory}</span> </div> </div> ) : error ? ( <div className='flex flex-1 items-center justify-center'> <div className='text-destructive'> - <p className='font-medium'>Error loading data</p> + <p className='font-medium'>{copy.dashboard.errorLoadingData}</p> <p className='text-sm'>{error}</p> </div> </div> ) : executions.length === 0 ? ( <div className='flex flex-1 items-center justify-center'> <div className='text-center text-muted-foreground'> - <p className='font-medium'>No execution history</p> - <p className='mt-1 text-sm'>Execute some workflows to see their history here</p> + <p className='font-medium'>{copy.dashboard.noExecutionHistory}</p> + <p className='mt-1 text-sm'>{copy.dashboard.noExecutionHistoryDescription}</p> </div> </div> ) : ( @@ -945,7 +952,7 @@ export function Dashboard() { className='h-9 rounded-md gap-2 border-border bg-background px-3' > <Filter className='h-4 w-4' /> - Filters + {copy.dashboard.filters.title} </Button> </PopoverTrigger> <PopoverContent className='w-[320px] p-0' align='start'> @@ -1113,7 +1120,9 @@ export function Dashboard() { <WorkflowDetails workspaceId={workspaceId} expandedWorkflowId={'__multi__'} - workflowName={`${selectedWorkflowIds.length} workflows selected`} + workflowName={formatTemplate(copy.dashboard.workflows.multipleSelected, { + count: selectedWorkflowIds.length, + })} overview={{ total: totalExecutions, success: totalSuccess, @@ -1314,7 +1323,7 @@ export function Dashboard() { <WorkflowDetails workspaceId={workspaceId} expandedWorkflowId={'all'} - workflowName={'All workflows'} + workflowName={copy.dashboard.workflows.allWorkflows} overview={{ total: totals.total, success: totals.success, failures, rate }} details={globalDetails as any} selectedSegmentIndex={[]} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx index 648e68084..b9360734a 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx @@ -22,9 +22,18 @@ interface FileDownloadProps { } isExecutionFile?: boolean // Flag to indicate this is an execution file className?: string + copy: { + downloading: string + download: string + } } -export function FileDownload({ file, isExecutionFile = false, className }: FileDownloadProps) { +export function FileDownload({ + file, + isExecutionFile = false, + className, + copy, +}: FileDownloadProps) { const [isDownloading, setIsDownloading] = useState(false) const handleDownload = async () => { @@ -81,7 +90,7 @@ export function FileDownload({ file, isExecutionFile = false, className }: FileD ) : ( <ArrowDown className='h-3 w-3' /> )} - {isDownloading ? 'Downloading...' : 'Download'} + {isDownloading ? copy.downloading : copy.download} </Button> ) } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/components/collapsible-input-output.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/components/collapsible-input-output.tsx index eb9008de7..c712aae17 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/components/collapsible-input-output.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/components/collapsible-input-output.tsx @@ -2,14 +2,16 @@ import { useState } from 'react' import { ChevronDown, ChevronRight } from 'lucide-react' import { BlockDataDisplay } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans' import type { TraceSpan } from '@/stores/logs/filters/types' +import type { TraceSpansCopy } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans' interface CollapsibleInputOutputProps { span: TraceSpan spanId: string depth: number + copy: Pick<TraceSpansCopy, 'inputSection' | 'errorSection' | 'outputSection'> } -export function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInputOutputProps) { +export function CollapsibleInputOutput({ span, spanId, depth, copy }: CollapsibleInputOutputProps) { const [inputExpanded, setInputExpanded] = useState(false) const [outputExpanded, setOutputExpanded] = useState(false) @@ -31,7 +33,7 @@ export function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInput ) : ( <ChevronRight className='h-3 w-3' /> )} - Input + {copy.inputSection} </button> {inputExpanded && ( <div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'> @@ -52,7 +54,7 @@ export function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInput ) : ( <ChevronRight className='h-3 w-3' /> )} - {span.status === 'error' ? 'Error' : 'Output'} + {span.status === 'error' ? copy.errorSection : copy.outputSection} </button> {outputExpanded && ( <div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/components/trace-span-item.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/components/trace-span-item.tsx index 2588e7abc..21ee1e603 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/components/trace-span-item.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/components/trace-span-item.tsx @@ -1,12 +1,14 @@ import type React from 'react' import { ChevronDown, ChevronRight, Code, RepeatIcon, SplitIcon, ToolCase } from 'lucide-react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { formatTemplate } from '@/i18n/public-copy' import { getIconTileStyle, sanitizeSolidIconColor } from '@/lib/ui/icon-colors' import { cn } from '@/lib/utils' import { CollapsibleInputOutput, normalizeChildWorkflowSpan, } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans' +import type { TraceSpansCopy } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans' import { scaleLogCostBreakdown } from '@/app/workspace/[workspaceId]/logs/utils' import { getBlock } from '@/blocks/registry' import { isSkillLoaderToolId } from '@/executor/handlers/agent/skills-resolver' @@ -16,6 +18,7 @@ import { getTool } from '@/tools/utils' interface TraceSpanItemProps { span: TraceSpan + copy: TraceSpansCopy depth: number totalDuration: number parentStartTime: number @@ -39,6 +42,7 @@ interface TraceSpanItemProps { export function TraceSpanItem({ span, + copy, depth, totalDuration, parentStartTime, @@ -120,8 +124,8 @@ export function TraceSpanItem({ } const formatRelativeTime = (ms: number) => { - if (ms === 0) return 'start' - return `+${ms}ms` + if (ms === 0) return copy.start + return formatTemplate(copy.plusMs, { ms }) } const getSpanColor = (type: string) => { @@ -175,7 +179,7 @@ export function TraceSpanItem({ if (span.type === 'tool') { const raw = String(span.name || '') if (isSkillLoaderToolId(raw)) { - return 'Load Skill' + return copy.loadSkill } const tool = getTool(raw) const displayName = (() => { @@ -194,7 +198,7 @@ export function TraceSpanItem({ if (span.name.includes('Initial response')) { return ( <> - Initial response{' '} + {copy.initialResponse}{' '} {modelName && <span className='text-xs opacity-75'>({modelName})</span>} </> ) @@ -202,14 +206,15 @@ export function TraceSpanItem({ if (span.name.includes('(iteration')) { return ( <> - Model response {modelName && <span className='text-xs opacity-75'>({modelName})</span>} + {copy.modelResponse}{' '} + {modelName && <span className='text-xs opacity-75'>({modelName})</span>} </> ) } if (span.name.includes('Model Generation')) { return ( <> - Model Generation{' '} + {copy.modelGeneration}{' '} {modelName && <span className='text-xs opacity-75'>({modelName})</span>} </> ) @@ -309,7 +314,7 @@ export function TraceSpanItem({ {String(span.model)} </span> </TooltipTrigger> - <TooltipContent side='top'>Model</TooltipContent> + <TooltipContent side='top'>{copy.model}</TooltipContent> </Tooltip> </TooltipProvider> )} @@ -331,26 +336,46 @@ export function TraceSpanItem({ <TooltipContent side='top'> {(() => { const t = span.tokens - if (typeof t === 'number') return <span>{t} tokens</span> + if (typeof t === 'number') { + return ( + <span className='font-normal text-xs'> + {formatTemplate(copy.tokens, { + count: t, + plural: t !== 1 ? 's' : '', + })} + </span> + ) + } const hasIn = typeof t.input === 'number' const hasOut = typeof t.output === 'number' - const input = hasIn ? t.input : undefined - const output = hasOut ? t.output : undefined const total = t.total ?? (hasIn && hasOut ? (t.input || 0) + (t.output || 0) : undefined) if (hasIn || hasOut) { + const inputValue = hasIn ? (t.input ?? '—') : '—' + const outputValue = hasOut ? (t.output ?? '—') : '—' + const totalSuffix = + typeof total === 'number' + ? formatTemplate(copy.tokensTotalSuffix, { count: total }) + : '' return ( <span className='font-normal text-xs'> - {`${hasIn ? input : '—'} in / ${hasOut ? output : '—'} out`} - {typeof total === 'number' ? ` (total ${total})` : ''} + {formatTemplate(copy.tokensInOut, { input: inputValue, output: outputValue })} + {totalSuffix} </span> ) } if (typeof total === 'number') - return <span className='font-normal text-xs'>Total {total} tokens</span> - return <span className='font-normal text-xs'>Tokens unavailable</span> + return ( + <span className='font-normal text-xs'> + {formatTemplate(copy.tokensTotal, { + count: total, + plural: total !== 1 ? 's' : '', + })} + </span> + ) + return <span className='font-normal text-xs'>{copy.tokensUnavailable}</span> })()} </TooltipContent> </Tooltip> @@ -378,14 +403,18 @@ export function TraceSpanItem({ return ( <div className='space-y-0.5'> {typeof input === 'number' && ( - <div className='text-xs'>Input: {formatCost(input)}</div> + <div className='text-xs'> + {copy.input}: {formatCost(input)} + </div> )} {typeof output === 'number' && ( - <div className='text-xs'>Output: {formatCost(output)}</div> + <div className='text-xs'> + {copy.output}: {formatCost(output)} + </div> )} {typeof total === 'number' && ( <div className='border-t pt-0.5 text-xs'> - Total: {formatCost(total)} + {copy.total}: {formatCost(total)} </div> )} </div> @@ -398,7 +427,7 @@ export function TraceSpanItem({ {showRelativeChip && depth > 0 && ( <span className='inline-flex items-center rounded bg-secondary px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground tabular-nums'> {span.relativeStartMs !== undefined - ? `+${span.relativeStartMs}ms` + ? formatTemplate(copy.plusMs, { ms: span.relativeStartMs }) : formatRelativeTime(startOffset)} </span> )} @@ -423,7 +452,7 @@ export function TraceSpanItem({ width: `${gapBeforePercent}%`, zIndex: 4, }} - title={`${gapBeforeMs.toFixed(0)}ms between blocks`} + title={formatTemplate(copy.betweenBlocks, { ms: gapBeforeMs.toFixed(0) })} /> )} @@ -496,7 +525,9 @@ export function TraceSpanItem({ width: `${Math.max(0.1, Math.min(100, gapWidthPercent))}%`, zIndex: 8, }} - title={`${Math.round(seg.startMs - prevEnd)}ms between blocks`} + title={formatTemplate(copy.betweenBlocks, { + ms: Math.round(seg.startMs - prevEnd), + })} /> ) } @@ -515,9 +546,11 @@ export function TraceSpanItem({ opacity: 1, zIndex: 6, }} - title={`${seg.type}${seg.name ? `: ${seg.name}` : ''} - ${Math.round( - seg.endMs - seg.startMs - )}ms`} + title={formatTemplate(copy.segmentTimingTooltip, { + type: seg.type, + nameSuffix: seg.name ? `: ${seg.name}` : '', + duration: Math.round(seg.endMs - seg.startMs), + })} /> ) } @@ -604,7 +637,7 @@ export function TraceSpanItem({ {expanded && ( <div> {(span.input || span.output) && ( - <CollapsibleInputOutput span={span} spanId={spanId} depth={depth} /> + <CollapsibleInputOutput span={span} spanId={spanId} depth={depth} copy={copy} /> )} {hasChildren && ( @@ -628,6 +661,7 @@ export function TraceSpanItem({ <TraceSpanItem key={index} span={enrichedChildSpan} + copy={copy} depth={depth + 1} totalDuration={totalDuration} parentStartTime={spanStartTime} @@ -674,6 +708,7 @@ export function TraceSpanItem({ <TraceSpanItem key={`tool-${index}`} span={toolSpan} + copy={copy} depth={depth + 1} totalDuration={totalDuration} parentStartTime={spanStartTime} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/index.ts b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/index.ts index e438ee639..a5a852251 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/index.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/index.ts @@ -2,4 +2,5 @@ export { BlockDataDisplay } from './components/block-data-display' export { CollapsibleInputOutput } from './components/collapsible-input-output' export { TraceSpanItem } from './components/trace-span-item' export { TraceSpans } from './trace-spans' +export type { TraceSpansCopy } from './trace-spans' export * from './utils' diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx index d6973eced..8b6d0e060 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx @@ -9,13 +9,48 @@ import { } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans' import type { TraceSpan } from '@/stores/logs/filters/types' +export interface TraceSpansCopy { + workflowExecution: string + collapseAll: string + expandAll: string + collapse: string + expand: string + noTraceData: string + model: string + loadSkill: string + initialResponse: string + modelResponse: string + modelGeneration: string + tokens: string + tokensUnavailable: string + tokensInOut: string + tokensTotal: string + tokensTotalSuffix: string + input: string + output: string + total: string + start: string + plusMs: string + betweenBlocks: string + inputSection: string + outputSection: string + errorSection: string + segmentTimingTooltip: string +} + interface TraceSpansProps { traceSpans?: TraceSpan[] totalDuration?: number costMultiplier?: number + copy: TraceSpansCopy } -export function TraceSpans({ traceSpans, totalDuration = 0, costMultiplier = 1 }: TraceSpansProps) { +export function TraceSpans({ + traceSpans, + totalDuration = 0, + costMultiplier = 1, + copy, +}: TraceSpansProps) { const [expandedSpans, setExpandedSpans] = useState<Set<string>>(new Set()) const containerRef = useRef<HTMLDivElement | null>(null) const timelineHitboxRef = useRef<HTMLDivElement | null>(null) @@ -44,7 +79,7 @@ export function TraceSpans({ traceSpans, totalDuration = 0, costMultiplier = 1 } }, [containerWidth]) if (!traceSpans || traceSpans.length === 0) { - return <div className='text-muted-foreground text-sm'>No trace data available</div> + return <div className='text-muted-foreground text-sm'>{copy.noTraceData}</div> } const workflowStartTime = traceSpans.reduce((earliest, span) => { @@ -137,7 +172,9 @@ export function TraceSpans({ traceSpans, totalDuration = 0, costMultiplier = 1 } <div className='w-full'> <div className='mb-2 flex items-center justify-between'> <div className='flex items-center gap-2'> - <div className='font-medium text-muted-foreground text-xs'>Workflow Execution</div> + <div className='font-medium text-muted-foreground text-xs'> + {copy.workflowExecution} + </div> </div> <div className='flex items-center gap-1'> {(() => { @@ -146,15 +183,15 @@ export function TraceSpans({ traceSpans, totalDuration = 0, costMultiplier = 1 } <button onClick={() => toggleAll(!anyExpanded)} className='rounded px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-card' - title={anyExpanded ? 'Collapse all' : 'Expand all'} + title={anyExpanded ? copy.collapseAll : copy.expandAll} > {anyExpanded ? ( <> - <Minimize2 className='mr-1 inline h-3.5 w-3.5' /> Collapse + <Minimize2 className='mr-1 inline h-3.5 w-3.5' /> {copy.collapse} </> ) : ( <> - <Maximize2 className='mr-1 inline h-3.5 w-3.5' /> Expand + <Maximize2 className='mr-1 inline h-3.5 w-3.5' /> {copy.expand} </> )} </button> @@ -190,6 +227,7 @@ export function TraceSpans({ traceSpans, totalDuration = 0, costMultiplier = 1 } <TraceSpanItem key={index} span={normalizedSpan} + copy={copy} depth={0} totalDuration={effectiveTotalDuration} parentStartTime={new Date(normalizedSpan.startTime).getTime()} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index dbd570df6..9c8ae51a7 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, ChevronUp, Eye, Loader2, X } from 'lucide-react' +import { useLocale } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { CopyButton } from '@/components/ui/copy-button' @@ -16,6 +17,8 @@ import { getTraceSpanDisplayCostMultiplier, } from '@/app/workspace/[workspaceId]/logs/utils' import { formatCost } from '@/providers/ai/utils' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import type { WorkflowLog } from '@/stores/logs/filters/types' interface LogDetailsProps { @@ -28,8 +31,8 @@ interface LogDetailsProps { hasPrev?: boolean } -const formatFileSize = (bytes?: number | null): string => { - if (bytes === null || bytes === undefined) return 'Unknown size' +const formatFileSize = (bytes?: number | null, unknownSize = 'Unknown size'): string => { + if (bytes === null || bytes === undefined) return unknownSize if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] @@ -53,6 +56,8 @@ export function LogDetails({ hasNext = false, hasPrev = false, }: LogDetailsProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.details const [isModelsExpanded, setIsModelsExpanded] = useState(false) const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false) const scrollAreaRef = useRef<HTMLDivElement>(null) @@ -74,8 +79,8 @@ export function LogDetails({ const formattedTimestamp = useMemo(() => { if (!log) return null - return formatDate(log.createdAt) - }, [log?.createdAt]) + return formatDate(log.createdAt, locale) + }, [log?.createdAt, locale]) const isWorkflowExecutionLog = useMemo(() => { if (!log) return false @@ -128,7 +133,7 @@ export function LogDetails({ if (!log) { return ( <div className='flex h-full min-h-0 min-w-0 items-center justify-center text-muted-foreground text-sm'> - Select a log to view details + {copy.selectLog} </div> ) } @@ -138,7 +143,7 @@ export function LogDetails({ <div className='flex h-full min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card'> {/* Header */} <div className='z-[9] flex items-center justify-between border-b px-3 py-2'> - <h2 className='font-medium text-foreground text-sm'>Log Details</h2> + <h2 className='font-medium text-foreground text-sm'>{copy.title}</h2> <div className='flex items-center gap-1'> <TooltipProvider> <Tooltip> @@ -149,12 +154,12 @@ export function LogDetails({ className='h-7 w-7 p-0' onClick={onNavigatePrev} disabled={!hasPrev} - aria-label='Previous log' + aria-label={copy.previous} > <ChevronUp className='h-4 w-4' /> </Button> </TooltipTrigger> - <TooltipContent side='bottom'>Previous log</TooltipContent> + <TooltipContent side='bottom'>{copy.previous}</TooltipContent> </Tooltip> </TooltipProvider> @@ -167,12 +172,12 @@ export function LogDetails({ className='h-7 w-7 p-0' onClick={onNavigateNext} disabled={!hasNext} - aria-label='Next log' + aria-label={copy.next} > <ChevronDown className='h-4 w-4' /> </Button> </TooltipTrigger> - <TooltipContent side='bottom'>Next log</TooltipContent> + <TooltipContent side='bottom'>{copy.next}</TooltipContent> </Tooltip> </TooltipProvider> @@ -181,7 +186,7 @@ export function LogDetails({ size='icon' className='h-7 w-7 p-0' onClick={onClose} - aria-label='Close' + aria-label={copy.close} > <X className='h-4 w-4' /> </Button> @@ -195,15 +200,19 @@ export function LogDetails({ {/* Timestamp & Workflow Row */} <div className='flex min-w-0 items-center gap-4'> <div className='flex w-[140px] flex-shrink-0 flex-col gap-2'> - <span className='font-medium text-muted-foreground text-xs'>Timestamp</span> + <span className='font-medium text-muted-foreground text-xs'> + {copy.timestamp} + </span> <div className='group relative flex items-center gap-2 pr-8 font-medium text-foreground text-sm'> - <span>{formattedTimestamp?.compactDate || 'N/A'}</span> - <span>{formattedTimestamp?.compactTime || 'N/A'}</span> + <span>{formattedTimestamp?.compactDate || copy.unknownValue}</span> + <span>{formattedTimestamp?.compactTime || copy.unknownValue}</span> </div> </div> <div className='flex min-w-0 flex-1 flex-col gap-2'> - <span className='font-medium text-muted-foreground text-xs'>Workflow</span> + <span className='font-medium text-muted-foreground text-xs'> + {copy.workflow} + </span> <div className='group relative flex min-w-0 items-center gap-2 pr-8'> <span className='min-w-0 truncate rounded-sm px-1 font-medium text-foreground text-sm' @@ -212,7 +221,7 @@ export function LogDetails({ color: log.workflow?.color, }} > - {log.workflow?.name || 'Unknown'} + {log.workflow?.name || copy.unknownWorkflow} </span> </div> </div> @@ -221,7 +230,9 @@ export function LogDetails({ {/* Execution ID */} {log.executionId && ( <div className='flex flex-col gap-1.5 rounded-md border bg-muted/30 px-3 py-2'> - <span className='font-medium text-muted-foreground text-xs'>Execution ID</span> + <span className='font-medium text-muted-foreground text-xs'> + {copy.executionId} + </span> <div className='group relative pr-8 font-mono text-foreground text-sm'> <CopyButton text={log.executionId} className='h-5 w-5' showLabel={false} /> <span className='block truncate'>{log.executionId}</span> @@ -232,17 +243,21 @@ export function LogDetails({ {/* Details Section */} <div className='-my-1 flex min-w-0 flex-col overflow-hidden rounded-md border'> <div className='group relative flex h-12 items-center justify-between border-b px-3'> - <span className='font-medium text-muted-foreground text-xs'>Level</span> + <span className='font-medium text-muted-foreground text-xs'> + {copy.level} + </span> <Badge variant={getLevelBadgeVariant(log.level)} className='h-6 rounded-md px-2 text-[11px] capitalize' > - {log.level || 'unknown'} + {log.level || copy.unknownLevel} </Badge> </div> <div className='group relative flex h-12 items-center justify-between border-b px-3'> - <span className='font-medium text-muted-foreground text-xs'>Trigger</span> + <span className='font-medium text-muted-foreground text-xs'> + {copy.trigger} + </span> {log.trigger ? ( <> <Badge @@ -258,7 +273,9 @@ export function LogDetails({ </div> <div className='group relative flex h-12 items-center justify-between px-3 pr-8'> - <span className='font-medium text-muted-foreground text-xs'>Duration</span> + <span className='font-medium text-muted-foreground text-xs'> + {copy.duration} + </span> <span className='font-medium text-foreground text-sm'>{log.duration || '—'}</span> {log.duration && ( <CopyButton text={log.duration} className='h-5 w-5' showLabel={false} /> @@ -270,21 +287,23 @@ export function LogDetails({ {isLoadingDetails && ( <div className='flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-muted-foreground'> <Loader2 className='h-4 w-4 animate-spin' /> - <span className='text-xs'>Loading details…</span> + <span className='text-xs'>{copy.loading}</span> </div> )} {/* Workflow State */} {isWorkflowExecutionLog && log.executionId && ( <div className='flex flex-col gap-2 rounded-md border bg-muted/30 px-3 py-2'> - <span className='font-medium text-muted-foreground text-xs'>Workflow State</span> + <span className='font-medium text-muted-foreground text-xs'> + {copy.workflowState} + </span> <Button variant='secondary' size='sm' onClick={() => setIsFrozenCanvasOpen(true)} className='w-full justify-between px-3' > - <span className='font-medium text-xs'>View Snapshot</span> + <span className='font-medium text-xs'>{copy.viewSnapshot}</span> <Eye className='h-4 w-4' /> </Button> </div> @@ -298,6 +317,7 @@ export function LogDetails({ traceSpans={log.executionData.traceSpans} totalDuration={log.executionData.totalDuration} costMultiplier={traceSpanCostMultiplier} + copy={copy.traceSpans} /> </div> </div> @@ -306,7 +326,9 @@ export function LogDetails({ {/* Tool Calls (if available) */} {log.executionData?.toolCalls && log.executionData.toolCalls.length > 0 && ( <div className='flex w-full flex-col gap-2 rounded-md border bg-muted/30 px-3 py-2'> - <span className='font-medium text-muted-foreground text-xs'>Tool Calls</span> + <span className='font-medium text-muted-foreground text-xs'> + {copy.toolCalls} + </span> <div className='w-full overflow-x-hidden rounded-md bg-background p-3'> <ToolCallsDisplay metadata={log.executionData} /> </div> @@ -317,7 +339,7 @@ export function LogDetails({ {log.files && log.files.length > 0 && ( <div className='flex w-full flex-col gap-2 rounded-md border bg-muted/30 px-3 py-2'> <span className='font-medium text-muted-foreground text-xs'> - Files ({log.files.length}) + {formatTemplate(copy.files, { count: log.files.length })} </span> <div className='flex flex-col gap-2'> {log.files.map((file, index) => ( @@ -330,17 +352,18 @@ export function LogDetails({ {file.name} </span> <span className='flex-shrink-0 text-muted-foreground text-xs'> - {formatFileSize(file.size)} + {formatFileSize(file.size, copy.unknownSize)} </span> </div> <div className='flex items-center justify-between gap-2'> <span className='text-[11px] text-muted-foreground'> - {file.type || 'Unknown type'} + {file.type || copy.unknownType} </span> <FileDownload file={file} isExecutionFile={true} className='!h-6 !px-2 text-[11px]' + copy={copy.download} /> </div> </div> @@ -352,23 +375,27 @@ export function LogDetails({ {/* Cost Information (moved to bottom) */} {hasCostInfo && ( <div className='flex flex-col gap-2'> - <span className='font-medium text-muted-foreground text-xs'>Cost Breakdown</span> + <span className='font-medium text-muted-foreground text-xs'> + {copy.costBreakdown} + </span> <div className='overflow-hidden rounded-md border'> <div className='flex flex-col gap-2 p-3'> <div className='flex items-center justify-between'> - <span className='text-muted-foreground text-xs'>Base Execution:</span> + <span className='text-muted-foreground text-xs'> + {copy.baseExecution} + </span> <span className='text-foreground text-xs'> {formatCost(baseExecutionCharge)} </span> </div> <div className='flex items-center justify-between'> - <span className='text-muted-foreground text-xs'>Model Input:</span> + <span className='text-muted-foreground text-xs'>{copy.modelInput}</span> <span className='text-foreground text-xs'> {formatCost(log.cost?.input || 0)} </span> </div> <div className='flex items-center justify-between'> - <span className='text-muted-foreground text-xs'>Model Output:</span> + <span className='text-muted-foreground text-xs'>{copy.modelOutput}</span> <span className='text-foreground text-xs'> {formatCost(log.cost?.output || 0)} </span> @@ -379,13 +406,13 @@ export function LogDetails({ <div className='flex flex-col gap-2 p-3'> <div className='flex items-center justify-between'> - <span className='text-muted-foreground text-xs'>Total:</span> + <span className='text-muted-foreground text-xs'>{copy.total}</span> <span className='text-foreground text-xs'> {formatCost(log.cost?.total || 0)} </span> </div> <div className='flex items-center justify-between'> - <span className='text-muted-foreground text-xs'>Tokens:</span> + <span className='text-muted-foreground text-xs'>{copy.tokens}</span> <span className='text-muted-foreground text-xs'> {log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '} out @@ -401,7 +428,9 @@ export function LogDetails({ className='flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/40' > <span className='font-medium text-muted-foreground text-xs'> - Model Breakdown ({Object.keys(log.cost?.models || {}).length}) + {formatTemplate(copy.modelBreakdown, { + count: Object.keys(log.cost?.models || {}).length, + })} </span> {isModelsExpanded ? ( <ChevronUp className='h-3 w-3 text-muted-foreground' /> @@ -418,21 +447,21 @@ export function LogDetails({ <div className='font-medium font-mono text-xs'>{model}</div> <div className='space-y-1 text-xs'> <div className='flex justify-between'> - <span className='text-muted-foreground'>Input:</span> + <span className='text-muted-foreground'>{copy.input}</span> <span>{formatCost(cost.input || 0)}</span> </div> <div className='flex justify-between'> - <span className='text-muted-foreground'>Output:</span> + <span className='text-muted-foreground'>{copy.output}</span> <span>{formatCost(cost.output || 0)}</span> </div> <div className='flex justify-between border-t pt-1'> - <span className='text-muted-foreground'>Total:</span> + <span className='text-muted-foreground'>{copy.total}</span> <span className='font-medium'> {formatCost(cost.total || 0)} </span> </div> <div className='flex justify-between'> - <span className='text-muted-foreground'>Tokens:</span> + <span className='text-muted-foreground'>{copy.tokens}</span> <span> {cost.tokens?.prompt || 0} in /{' '} {cost.tokens?.completion || 0} out @@ -449,8 +478,9 @@ export function LogDetails({ <div className='border-t bg-muted/40 p-2 text-[11px] text-muted-foreground'> <p> - Total cost includes a base execution charge of{' '} - {formatCost(baseExecutionCharge)} plus any model usage costs. + {formatTemplate(copy.totalCostNote, { + amount: formatCost(baseExecutionCharge), + })} </p> </div> </div> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx index bf097f509..bbc5a2d51 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx @@ -2,10 +2,13 @@ import type { RefObject } from 'react' import { AlertCircle, Info, Loader2 } from 'lucide-react' +import { useLocale } from 'next-intl' import { TooltipProvider } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import Timeline from '@/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/timeline' import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import type { WorkflowLog } from '@/stores/logs/filters/types' const getTriggerColor = (trigger: string | null | undefined): string => { @@ -52,6 +55,8 @@ export function LogsList({ scrollContainerRef, selectedRowRef, }: LogsListProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs return ( <div className='flex h-full max-h-full min-h-0 min-w-0 flex-1 overflow-hidden'> <div className='flex h-full max-h-full min-h-0 flex-1 flex-col overflow-hidden'> @@ -78,27 +83,33 @@ export function LogsList({ <thead> <tr> <th className='px-4 pt-2 pb-3 text-center align-middle font-medium'> - <span className='text-muted-foreground text-xs leading-none'>Time</span> + <span className='text-muted-foreground text-xs leading-none'> + {copy.list.headers.time} + </span> </th> <th className='px-4 pt-2 pb-3 text-center align-middle font-medium'> - <span className='text-muted-foreground text-xs leading-none'>Status</span> + <span className='text-muted-foreground text-xs leading-none'> + {copy.list.headers.status} + </span> </th> <th className='px-4 pt-2 pb-3 text-center align-middle font-medium'> <span className='text-muted-foreground text-xs leading-none'> - Workflow + {copy.list.headers.workflow} </span> </th> <th className='px-4 pt-2 pb-3 text-center align-middle font-medium'> - <span className='text-muted-foreground text-xs leading-none'>Cost</span> + <span className='text-muted-foreground text-xs leading-none'> + {copy.list.headers.cost} + </span> </th> <th className='hidden px-4 pt-2 pb-3 text-center align-middle font-medium xl:table-cell'> <span className='text-muted-foreground text-xs leading-none'> - Trigger + {copy.list.headers.trigger} </span> </th> <th className='hidden px-4 pt-2 pb-3 text-center align-middle font-medium xl:table-cell'> <span className='text-muted-foreground text-xs leading-none'> - Duration + {copy.list.headers.duration} </span> </th> </tr> @@ -115,21 +126,21 @@ export function LogsList({ <div className='flex h-full items-center justify-center p-5'> <div className='flex items-center gap-2 text-muted-foreground'> <Loader2 className='h-5 w-5 animate-spin' /> - <span className='text-sm'>Loading logs...</span> + <span className='text-sm'>{copy.list.loading}</span> </div> </div> ) : error ? ( <div className='flex h-full items-center justify-center'> <div className='flex items-center gap-2 text-destructive'> <AlertCircle className='h-5 w-5' /> - <span className='text-sm'>Error: {error}</span> + <span className='text-sm'>{error}</span> </div> </div> ) : logs.length === 0 ? ( <div className='flex h-full items-center justify-center'> <div className='flex items-center gap-2 text-muted-foreground'> <Info className='h-5 w-5' /> - <span className='text-sm'>No logs found</span> + <span className='text-sm'>{copy.list.noLogs}</span> </div> </div> ) : ( @@ -144,7 +155,7 @@ export function LogsList({ </colgroup> <tbody> {logs.map((log) => { - const formattedDate = formatDate(log.createdAt) + const formattedDate = formatDate(log.createdAt, locale) const isSelected = selectedLogId === log.id return ( @@ -184,7 +195,7 @@ export function LogsList({ </td> <td className='px-4 py-3 text-center align-middle'> <div className='truncate font-medium text-[13px]'> - {log.workflow?.name || 'Unknown Workflow'} + {log.workflow?.name || copy.list.unknownWorkflow} </div> </td> <td className='px-4 py-3 text-center align-middle'> @@ -232,10 +243,10 @@ export function LogsList({ {isFetchingMore ? ( <> <Loader2 className='h-4 w-4 animate-spin' /> - <span className='text-sm'>Loading more...</span> + <span className='text-sm'>{copy.list.loadingMore}</span> </> ) : ( - <span className='text-sm'>Scroll to load more</span> + <span className='text-sm'>{copy.list.scrollToLoadMore}</span> )} </div> </td> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/filter-section.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/filter-section.tsx index c5fad1ef7..40dceb33d 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/filter-section.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/filter-section.tsx @@ -1,9 +1,11 @@ export default function FilterSection({ title, content, + emptyMessage, }: { title: string content?: React.ReactNode + emptyMessage?: string }) { return ( <div className='space-y-1'> @@ -11,7 +13,7 @@ export default function FilterSection({ <div> {content || ( <div className='text-muted-foreground text-sm'> - Filter options for {title} will go here + {emptyMessage || `Filter options for ${title} will go here`} </div> )} </div> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/folder.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/folder.tsx index 8f8a86385..93144ac53 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/folder.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/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 { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Command, @@ -21,6 +22,8 @@ import { filterButtonClass, folderDropdownListStyle, } from './shared' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { useFolders } from '@/hooks/queries/folders' import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' import { useFilterStore } from '@/stores/logs/filters/store' @@ -33,6 +36,8 @@ interface FolderOption { } export default function FolderFilter() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.dashboard.filters const triggerRef = useRef<HTMLButtonElement | null>(null) const { folderIds, toggleFolderId, setFolderIds } = useFilterStore() const { getFolderTree } = useFolderStore() @@ -68,12 +73,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 copy.allFolders if (folderIds.length === 1) { const selected = folders.find((f) => f.id === folderIds[0]) - return selected ? selected.name : 'All folders' + return selected ? selected.name : copy.allFolders } - return `${folderIds.length} folders selected` + return formatTemplate(copy.selectedFolders, { + count: folderIds.length, + plural: folderIds.length !== 1 ? 's' : '', + }) } // Check if a folder is selected @@ -90,7 +98,7 @@ export default function FolderFilter() { <DropdownMenu> <DropdownMenuTrigger asChild> <Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}> - {foldersLoading ? 'Loading folders...' : getSelectedFoldersText()} + {foldersLoading ? copy.loadingFolders : getSelectedFoldersText()} <ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' /> </Button> </DropdownMenuTrigger> @@ -102,9 +110,9 @@ export default function FolderFilter() { className={dropdownContentClass} > <Command> - <CommandInput placeholder='Search folders...' onValueChange={(v) => setSearch(v)} /> + <CommandInput placeholder={copy.searchFolders} onValueChange={(v) => setSearch(v)} /> <CommandList className={commandListClass} style={folderDropdownListStyle}> - <CommandEmpty>{foldersLoading ? 'Loading folders...' : 'No folders found.'}</CommandEmpty> + <CommandEmpty>{foldersLoading ? copy.loadingFolders : copy.noFolders}</CommandEmpty> <CommandGroup> <CommandItem value='all-folders' @@ -113,7 +121,7 @@ export default function FolderFilter() { }} className='cursor-pointer' > - <span>All folders</span> + <span>{copy.allFolders}</span> {folderIds.length === 0 && ( <Check className='ml-auto h-4 w-4 text-muted-foreground' /> )} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/level.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/level.tsx index 321e9a2b8..d92290ed7 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/level.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/level.tsx @@ -1,3 +1,4 @@ +import { useLocale } from 'next-intl' import { Check, ChevronDown } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -7,20 +8,24 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { useFilterStore } from '@/stores/logs/filters/store' import type { LogLevel } from '@/stores/logs/filters/types' export default function Level() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).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' }, + { value: 'error', label: copy.error, color: 'bg-destructive/100' }, + { value: 'info', label: copy.info, color: 'bg-muted-foreground/100' }, ] const getDisplayLabel = () => { - if (level === 'all') return 'Any status' + if (level === 'all') return copy.anyStatus const selected = specificLevels.find((l) => l.value === level) - return selected ? selected.label : 'Any status' + return selected ? selected.label : copy.anyStatus } return ( @@ -47,7 +52,7 @@ 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' > - <span>Any status</span> + <span>{copy.anyStatus}</span> {level === 'all' && <Check className='h-4 w-4 text-muted-foreground' />} </DropdownMenuItem> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/timeline.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/timeline.tsx index 6dd29eb54..55ebd033b 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/timeline.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/timeline.tsx @@ -1,4 +1,5 @@ import { Check, ChevronDown } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -13,6 +14,8 @@ import { filterButtonClass, timelineDropdownListStyle, } from './shared' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { useFilterStore } from '@/stores/logs/filters/store' import type { TimeRange } from '@/stores/logs/filters/types' @@ -21,6 +24,8 @@ type TimelineProps = { } export default function Timeline({ variant = 'default' }: TimelineProps = {}) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.dashboard.filters const { timeRange, setTimeRange } = useFilterStore() const specificTimeRanges: TimeRange[] = [ 'Past 30 minutes', @@ -34,11 +39,24 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) { 'Past 30 days', ] + const timelineLabels: Record<TimeRange, string> = { + 'All time': copy.allTime, + 'Past 30 minutes': copy.past30Minutes, + 'Past hour': copy.pastHour, + 'Past 6 hours': copy.past6Hours, + 'Past 12 hours': copy.past12Hours, + 'Past 24 hours': copy.past24Hours, + 'Past 3 days': copy.past3Days, + 'Past 7 days': copy.past7Days, + 'Past 14 days': copy.past14Days, + 'Past 30 days': copy.past30Days, + } + return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant='outline' size='sm' className={filterButtonClass}> - {timeRange} + {timelineLabels[timeRange] ?? timeRange} <ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' /> </Button> </DropdownMenuTrigger> @@ -60,7 +78,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' > - <span>All time</span> + <span>{copy.allTime}</span> {timeRange === 'All time' && <Check className='h-4 w-4 text-muted-foreground' />} </DropdownMenuItem> @@ -74,7 +92,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' > - <span>{range}</span> + <span>{timelineLabels[range] ?? range}</span> {timeRange === range && <Check className='h-4 w-4 text-muted-foreground' />} </DropdownMenuItem> ))} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/trigger.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/trigger.tsx index 08e9d2e16..0cbf2a158 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/trigger.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/trigger.tsx @@ -1,5 +1,6 @@ import { useRef } from 'react' import { Check, ChevronDown } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -8,29 +9,36 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { dropdownContentClass, filterButtonClass } from './shared' import { useFilterStore } from '@/stores/logs/filters/store' import type { TriggerType } from '@/stores/logs/filters/types' export default function Trigger() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.dashboard.filters const { triggers, toggleTrigger, setTriggers } = useFilterStore() const triggerRef = useRef<HTMLButtonElement | null>(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' }, + { value: 'manual', label: copy.manual, color: 'bg-gray-500' }, + { value: 'api', label: copy.api, color: 'bg-blue-500' }, + { value: 'webhook', label: copy.webhook, color: 'bg-orange-500' }, + { value: 'schedule', label: copy.schedule, color: 'bg-green-500' }, + { value: 'chat', label: copy.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 copy.allTriggers if (triggers.length === 1) { const selected = triggerOptions.find((t) => t.value === triggers[0]) - return selected ? selected.label : 'All triggers' + return selected ? selected.label : copy.allTriggers } - return `${triggers.length} triggers selected` + return formatTemplate(copy.selectedTriggers, { + count: triggers.length, + plural: triggers.length !== 1 ? 's' : '', + }) } // Check if a trigger is selected @@ -63,7 +71,7 @@ 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' > - <span>All triggers</span> + <span>{copy.allTriggers}</span> {triggers.length === 0 && <Check className='h-4 w-4 text-muted-foreground' />} </DropdownMenuItem> <DropdownMenuSeparator /> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/workflow.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/workflow.tsx index 462672dab..04ba530fe 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/workflow.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/components/workflow.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown } from 'lucide-react' import { useParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Command, @@ -16,6 +17,8 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { createLogger } from '@/lib/logs/console/logger' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { commandListClass, dropdownContentClass, @@ -33,6 +36,8 @@ interface WorkflowOption { } export default function Workflow() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.dashboard.filters const triggerRef = useRef<HTMLButtonElement | null>(null) const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore() const params = useParams() @@ -72,12 +77,15 @@ export default function Workflow() { }, [workspaceId, folderIds]) const getSelectedWorkflowsText = () => { - if (workflowIds.length === 0) return 'All workflows' + if (workflowIds.length === 0) return copy.allWorkflows if (workflowIds.length === 1) { const selected = workflows.find((w) => w.id === workflowIds[0]) - return selected ? selected.name : 'All workflows' + return selected ? selected.name : copy.allWorkflows } - return `${workflowIds.length} workflows selected` + return formatTemplate(copy.selectedWorkflows, { + count: workflowIds.length, + plural: workflowIds.length !== 1 ? 's' : '', + }) } const isWorkflowSelected = (workflowId: string) => { @@ -92,7 +100,7 @@ export default function Workflow() { <DropdownMenu> <DropdownMenuTrigger asChild> <Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}> - {loading ? 'Loading workflows...' : getSelectedWorkflowsText()} + {loading ? copy.loadingWorkflows : getSelectedWorkflowsText()} <ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' /> </Button> </DropdownMenuTrigger> @@ -104,9 +112,9 @@ export default function Workflow() { className={dropdownContentClass} > <Command> - <CommandInput placeholder='Search workflows...' onValueChange={(v) => setSearch(v)} /> + <CommandInput placeholder={copy.searchWorkflows} onValueChange={(v) => setSearch(v)} /> <CommandList className={commandListClass} style={workflowDropdownListStyle}> - <CommandEmpty>{loading ? 'Loading workflows...' : 'No workflows found.'}</CommandEmpty> + <CommandEmpty>{loading ? copy.loadingWorkflows : copy.noWorkflows}</CommandEmpty> <CommandGroup> <CommandItem value='all-workflows' @@ -115,7 +123,7 @@ export default function Workflow() { }} className='cursor-pointer' > - <span>All workflows</span> + <span>{copy.allWorkflows}</span> {workflowIds.length === 0 && ( <Check className='ml-auto h-4 w-4 text-muted-foreground' /> )} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/filters.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/filters.tsx index 016fbebb8..a747d2423 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/filters.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/filters/filters.tsx @@ -1,9 +1,12 @@ 'use client' import { TimerOff } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui' import { isProd } from '@/lib/environment' import { getSubscriptionStatus } from '@/lib/subscription/helpers' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { useSubscriptionData } from '@/hooks/queries/subscription' import { FilterSection, FolderFilter, Level, Timeline, Trigger, Workflow } from './components' @@ -11,6 +14,8 @@ import { FilterSection, FolderFilter, Level, Timeline, Trigger, Workflow } from * Filters component for logs page - includes timeline and other filter options */ export function Filters() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.dashboard.filters const { data: subscriptionData, isLoading } = useSubscriptionData() const billingPayload = (subscriptionData as any)?.data ?? subscriptionData const subscription = getSubscriptionStatus(billingPayload) @@ -32,11 +37,11 @@ export function Filters() { <div className='mb-4 overflow-hidden rounded-md border border-border'> <div className='flex items-center gap-2 border-b bg-background p-3'> <TimerOff className='h-4 w-4 text-muted-foreground' /> - <span className='font-medium text-sm'>Log Retention Policy</span> + <span className='font-medium text-sm'>{copy.retentionPolicy}</span> </div> <div className='p-3'> <p className='text-muted-foreground text-xs'> - Logs are automatically deleted after {retentionDays} days on this tier. + {formatTemplate(copy.retentionDescription, { days: retentionDays })} </p> {!isPaid ? ( <div className='mt-2.5'> @@ -46,7 +51,7 @@ export function Filters() { className='h-8 w-full px-3 py-1.5 text-xs' onClick={handleUpgradeClick} > - Upgrade Plan + {copy.upgradePlan} </Button> </div> ) : null} @@ -54,22 +59,22 @@ export function Filters() { </div> )} - <h2 className='mb-4 pl-2 font-medium text-sm'>Filters</h2> + <h2 className='mb-4 pl-2 font-medium text-sm'>{copy.title}</h2> {/* Level Filter */} - <FilterSection title='Level' content={<Level />} /> + <FilterSection title={copy.level} content={<Level />} /> {/* Workflow Filter */} - <FilterSection title='Workflow' content={<Workflow />} /> + <FilterSection title={copy.workflow} content={<Workflow />} /> {/* Folder Filter */} - <FilterSection title='Folder' content={<FolderFilter />} /> + <FilterSection title={copy.folder} content={<FolderFilter />} /> {/* Trigger Filter */} - <FilterSection title='Trigger' content={<Trigger />} /> + <FilterSection title={copy.trigger} content={<Trigger />} /> {/* Timeline Filter */} - <FilterSection title='Timeline' content={<Timeline />} /> + <FilterSection title={copy.timeline} content={<Timeline />} /> </div> ) } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx index 5c8da5204..f4ed73dbb 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Search, X } from 'lucide-react' +import { useLocale } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -12,6 +13,8 @@ import { type WorkflowData, } from '@/lib/logs/search-suggestions' import { cn } from '@/lib/utils' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -31,7 +34,7 @@ interface AutocompleteSearchProps { export function AutocompleteSearch({ value, onChange, - placeholder = 'Search logs...', + placeholder, availableWorkflows = [], availableFolders = [], className, @@ -39,8 +42,11 @@ export function AutocompleteSearch({ showActiveFilters = true, showTextSearchIndicator = true, }: AutocompleteSearchProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.dashboard.filters const workflows = useWorkflowRegistry((state) => state.workflows) const folders = useFolderStore((state) => state.folders) + const getFilterLabel = (field: string) => (copy as Record<string, string>)[field] ?? field const fallbackWorkflowData = useMemo<WorkflowData[]>(() => { return availableWorkflows.map((name, index) => ({ @@ -206,7 +212,7 @@ export function AutocompleteSearch({ removeBadge(index) }} > - <span className='text-muted-foreground'>{filter.field}:</span> + <span className='text-muted-foreground'>{getFilterLabel(filter.field)}:</span> <span className='text-foreground'> {filter.operator !== '=' && filter.operator} {filter.originalValue} @@ -237,7 +243,7 @@ export function AutocompleteSearch({ <input ref={inputRef} type='text' - placeholder={hasFilters || hasTextSearch ? '' : placeholder} + placeholder={hasFilters || hasTextSearch ? '' : placeholder || copy.searchPlaceholder} value={currentInput} onChange={(e) => handleInputChange(e.target.value)} onKeyDown={handleKeyDown} @@ -327,7 +333,7 @@ export function AutocompleteSearch({ <div className='flex-shrink-0 font-mono text-[11px] text-muted-foreground'> {suggestion.category === 'workflow' || suggestion.category === 'folder' - ? `${suggestion.category}:` + ? `${getFilterLabel(suggestion.category)}:` : ''} </div> )} @@ -342,7 +348,7 @@ export function AutocompleteSearch({ <div className='py-1'> {suggestionType === 'filters' && ( <div className='border-b border-border/50 px-3 py-1.5 font-medium text-[11px] uppercase tracking-wide text-muted-foreground/80'> - Suggested Filters + {copy.suggestedFilters} </div> )} @@ -381,14 +387,14 @@ export function AutocompleteSearch({ {showActiveFilters && hasFilters && ( <div className='mt-3 flex flex-wrap items-center gap-2'> - <span className='font-medium text-xs text-muted-foreground'>Active Filters:</span> + <span className='font-medium text-xs text-muted-foreground'>{copy.activeFilters}</span> {appliedFilters.map((filter, index) => ( <Badge key={`${filter.field}-${filter.value}-${index}`} variant='secondary' className='h-6 border border-border/50 bg-muted/50 font-mono text-xs text-muted-foreground' > - <span className='mr-1'>{filter.field}:</span> + <span className='mr-1'>{getFilterLabel(filter.field)}:</span> <span> {filter.operator !== '=' && filter.operator} {filter.originalValue} @@ -418,7 +424,7 @@ export function AutocompleteSearch({ initializeFromQuery(textSearch, []) }} > - Clear all + {copy.clearAll} </Button> )} </div> @@ -426,7 +432,7 @@ export function AutocompleteSearch({ {showTextSearchIndicator && hasTextSearch && ( <div className='mt-2 flex items-center gap-2'> - <span className='font-medium text-xs text-muted-foreground'>Text Search:</span> + <span className='font-medium text-xs text-muted-foreground'>{copy.textSearch}</span> <Badge variant='outline' className='text-xs'> "{textSearch}" </Badge> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitor-editor-modal.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitor-editor-modal.tsx index b4754faa2..68c54536c 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitor-editor-modal.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitor-editor-modal.tsx @@ -1,6 +1,7 @@ 'use client' import { Activity, Workflow as WorkflowIcon } from 'lucide-react' +import { useLocale } from 'next-intl' import { StockSelector } from '@/components/listing-selector/selector/input' import { Button } from '@/components/ui/button' import { @@ -18,6 +19,8 @@ import type { SubBlockConfig } from '@/blocks/types' import type { MarketProviderParamDefinition } from '@/providers/market/providers' import { getMarketSeriesCapabilities } from '@/providers/market/providers' import { ShortInput } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/short-input' +import { getPublicCopy, formatTemplate } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { SearchableDropdown } from './searchable-dropdown' import type { IndicatorOption, @@ -72,14 +75,15 @@ export function MonitorEditorModal({ onUpdateSecretValue, onUpdateProviderParamValue, }: MonitorEditorModalProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.editor + return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className='max-w-3xl'> <DialogHeader> - <DialogTitle>{editingKey ? 'Edit Monitor' : 'Add Monitor'}</DialogTitle> - <DialogDescription> - Configure provider, auth, listing, indicator, interval, and workflow target. - </DialogDescription> + <DialogTitle>{editingKey ? copy.editTitle : copy.createTitle}</DialogTitle> + <DialogDescription>{copy.description}</DialogDescription> </DialogHeader> {draft ? ( @@ -91,7 +95,7 @@ export function MonitorEditorModal({ )} > <div className='space-y-2'> - <p className='text-muted-foreground text-xs'>Provider</p> + <p className='text-muted-foreground text-xs'>{copy.provider}</p> <SearchableDropdown value={draft.providerId} options={streamingProviders.map((provider) => ({ @@ -100,9 +104,9 @@ export function MonitorEditorModal({ icon: provider.icon, searchValue: `${provider.name} ${provider.id}`, }))} - placeholder='Select provider' - searchPlaceholder='Search providers...' - emptyText='No providers found.' + placeholder={copy.selectProvider} + searchPlaceholder={copy.searchProviders} + emptyText={copy.noProviders} onValueChange={(nextProviderId) => { const nextIntervals = getMarketSeriesCapabilities(nextProviderId)?.intervals ?? [] @@ -127,7 +131,7 @@ export function MonitorEditorModal({ selected ? 'text-foreground' : 'text-muted-foreground' )} > - {selected?.label || 'Select provider'} + {selected?.label || copy.selectProvider} </span> </div> ) @@ -152,7 +156,7 @@ export function MonitorEditorModal({ {nonSecretDefinitions.length > 0 ? ( <div className='space-y-2'> - <p className='text-muted-foreground text-xs'>Feed</p> + <p className='text-muted-foreground text-xs'>{copy.feed}</p> {nonSecretDefinitions.map((definition) => { const key = `param:${definition.id}` const value = draft.providerParamValues[definition.id] ?? '' @@ -168,8 +172,10 @@ export function MonitorEditorModal({ searchValue: `${option.label} ${option.id}`, }))} placeholder={definition.title || definition.id} - searchPlaceholder={`Search ${definition.title || definition.id}...`} - emptyText='No options found.' + searchPlaceholder={formatTemplate(copy.searchOptions, { + title: definition.title || definition.id, + })} + emptyText={copy.noOptions} onValueChange={(nextValue) => onUpdateProviderParamValue(definition.id, nextValue) } @@ -207,7 +213,7 @@ export function MonitorEditorModal({ {secretDefinitions.length > 0 ? ( <div className='space-y-2'> - <p className='text-muted-foreground text-xs'>Auth</p> + <p className='text-muted-foreground text-xs'>{copy.auth}</p> <div className={cn( 'grid gap-3', @@ -254,7 +260,7 @@ export function MonitorEditorModal({ <div className='grid gap-3 sm:grid-cols-2'> <div className='space-y-2'> - <p className='text-muted-foreground text-xs'>Listing</p> + <p className='text-muted-foreground text-xs'>{copy.listing}</p> {listingInstanceId ? ( <StockSelector instanceId={listingInstanceId} @@ -270,16 +276,16 @@ export function MonitorEditorModal({ </div> <div className='space-y-2'> - <p className='text-muted-foreground text-xs'>Interval</p> + <p className='text-muted-foreground text-xs'>{copy.interval}</p> <SearchableDropdown value={draft.interval} options={providerIntervals.map((interval) => ({ value: interval, label: interval, }))} - placeholder='Select interval' - searchPlaceholder='Search intervals...' - emptyText='No intervals found.' + placeholder={copy.selectInterval} + searchPlaceholder={copy.searchIntervals} + emptyText={copy.noIntervals} onValueChange={(value) => onUpdateDraft({ interval: value })} /> {errors.interval ? ( @@ -290,7 +296,7 @@ export function MonitorEditorModal({ <div className='grid gap-3 sm:grid-cols-2'> <div className='space-y-2'> - <p className='text-muted-foreground text-xs'>Workflow</p> + <p className='text-muted-foreground text-xs'>{copy.workflow}</p> <SearchableDropdown value={draft.workflowId} options={workflowPickerOptions.map((option) => ({ @@ -299,9 +305,9 @@ export function MonitorEditorModal({ label: option.workflowName, searchValue: `${option.workflowName} ${option.workflowId}`, }))} - placeholder='Select workflow' - searchPlaceholder='Search workflows...' - emptyText='No workflows found.' + placeholder={copy.selectWorkflow} + searchPlaceholder={copy.searchWorkflows} + emptyText={copy.noWorkflows} onValueChange={(workflowId) => { const preferredTarget = workflowTargets.find( @@ -334,7 +340,7 @@ export function MonitorEditorModal({ selected ? 'text-foreground' : 'text-muted-foreground' )} > - {selected?.workflowName || 'Select workflow'} + {selected?.workflowName || copy.selectWorkflow} </span> </div> )} @@ -362,7 +368,7 @@ export function MonitorEditorModal({ </div> <div className='space-y-2'> - <p className='text-muted-foreground text-xs'>Indicator</p> + <p className='text-muted-foreground text-xs'>{copy.indicator}</p> <SearchableDropdown value={draft.indicatorId} options={indicatorPickerOptions.map((option) => ({ @@ -371,9 +377,9 @@ export function MonitorEditorModal({ label: option.name, searchValue: `${option.name} ${option.id}`, }))} - placeholder='Select indicator' - searchPlaceholder='Search indicators...' - emptyText='No indicators found.' + placeholder={copy.selectIndicator} + searchPlaceholder={copy.searchIndicators} + emptyText={copy.noIndicators} onValueChange={(indicatorId) => onUpdateDraft({ indicatorId })} renderTriggerValue={(selected) => ( <div className='flex min-w-0 items-center gap-2'> @@ -395,7 +401,7 @@ export function MonitorEditorModal({ selected ? 'text-foreground' : 'text-muted-foreground' )} > - {selected?.name || 'Select indicator'} + {selected?.name || copy.selectIndicator} </span> </div> )} @@ -422,10 +428,10 @@ export function MonitorEditorModal({ <DialogFooter> <Button variant='outline' onClick={onCancel} disabled={saving}> - Cancel + {copy.cancel} </Button> <Button onClick={onSave} disabled={saving}> - {saving ? 'Saving...' : editingKey ? 'Save Changes' : 'Create Monitor'} + {saving ? copy.saving : editingKey ? copy.save : copy.create} </Button> </DialogFooter> </DialogContent> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitor-table.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitor-table.tsx index b81d7f2ee..ab2a988db 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitor-table.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitor-table.tsx @@ -8,6 +8,7 @@ import { Trash2, Workflow as WorkflowIcon, } from 'lucide-react' +import { useLocale } from 'next-intl' import { MarketListingRow } from '@/components/listing-selector/listing/row' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -18,6 +19,8 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import type { ListingOption } from '@/lib/listing/identity' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import type { IndicatorMonitorRecord, IndicatorOption, @@ -90,12 +93,15 @@ export function MonitorTable({ onToggleMonitorState, onRemoveMonitor, }: MonitorTableProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.monitors + return ( <div className='m-1 flex h-full max-h-full min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card'> <div className='h-full max-h-full min-h-0 overflow-auto'> {monitorsLoading || referenceLoading ? ( <div className='flex h-full items-center justify-center gap-2 text-muted-foreground text-sm'> - Loading monitors... + {copy.loading} </div> ) : monitorsError ? ( <div className='flex h-full items-center justify-center gap-2 px-4 text-destructive text-sm'> @@ -106,25 +112,25 @@ export function MonitorTable({ <thead className='sticky top-0 z-10 border-b bg-card'> <tr> <th className='px-4 pt-2 pb-3 text-center align-middle font-medium'> - <span className='text-muted-foreground text-xs leading-none'>Status</span> + <span className='text-muted-foreground text-xs leading-none'>{copy.status}</span> </th> <th className='px-4 pt-2 pb-3 text-center align-middle font-medium'> - <span className='text-muted-foreground text-xs leading-none'>Provider</span> + <span className='text-muted-foreground text-xs leading-none'>{copy.provider}</span> </th> <th className='px-4 pt-2 pb-3 text-center align-middle font-medium'> - <span className='text-muted-foreground text-xs leading-none'>Auth</span> + <span className='text-muted-foreground text-xs leading-none'>{copy.auth}</span> </th> <th className='px-4 pt-2 pb-3 text-center align-middle font-medium'> - <span className='text-muted-foreground text-xs leading-none'>Listing</span> + <span className='text-muted-foreground text-xs leading-none'>{copy.listing}</span> </th> <th className='px-4 pt-2 pb-3 text-center align-middle font-medium'> - <span className='text-muted-foreground text-xs leading-none'>Indicator</span> + <span className='text-muted-foreground text-xs leading-none'>{copy.indicator}</span> </th> <th className='px-4 pt-2 pb-3 text-center align-middle font-medium'> - <span className='text-muted-foreground text-xs leading-none'>Workflow</span> + <span className='text-muted-foreground text-xs leading-none'>{copy.workflow}</span> </th> <th className='px-4 pt-2 pb-3 text-center align-middle font-medium'> - <span className='text-muted-foreground text-xs leading-none'>Actions</span> + <span className='text-muted-foreground text-xs leading-none'>{copy.actions}</span> </th> </tr> </thead> @@ -135,7 +141,7 @@ export function MonitorTable({ colSpan={7} className='px-4 py-6 text-center align-middle text-muted-foreground text-sm' > - No monitors configured. + {copy.noConfigured} </td> </tr> ) : ( @@ -170,7 +176,7 @@ export function MonitorTable({ : 'bg-gray-500/20 text-gray-500' }`} > - {monitor.isActive ? 'Active' : 'Paused'} + {monitor.isActive ? copy.active : copy.paused} </Badge> </td> <td className='p-3 text-center align-middle'> @@ -186,7 +192,7 @@ export function MonitorTable({ variant={authConfigured ? 'default' : 'secondary'} className='h-6 items-center justify-center rounded-md px-2 text-[11px]' > - {authConfigured ? 'Configured' : 'Missing'} + {authConfigured ? copy.configured : copy.missing} </Badge> </td> <td className='p-3 text-center align-middle'> @@ -195,7 +201,7 @@ export function MonitorTable({ listing={listingOption} showAssetClass className='w-full pl-1 rounded-md border border-border' - placeholderTitle='Listing' + placeholderTitle={copy.listing} /> </div> </td> @@ -261,7 +267,7 @@ export function MonitorTable({ }} > <Pen className='mr-2 h-4 w-4' /> - Edit + {copy.edit} </DropdownMenuItem> <DropdownMenuItem disabled={ @@ -276,12 +282,12 @@ export function MonitorTable({ {monitor.isActive ? ( <> <Pause className='mr-2 h-4 w-4' /> - Pause + {copy.pause} </> ) : ( <> <Play className='mr-2 h-4 w-4' /> - Activate + {copy.activate} </> )} </DropdownMenuItem> @@ -294,7 +300,7 @@ export function MonitorTable({ }} > <Trash2 className='mr-2 h-4 w-4' /> - Remove + {copy.remove} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitors-view.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitors-view.tsx index 8a973b255..6c2f83fbe 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitors-view.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/components/monitors/monitors-view.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useLocale } from 'next-intl' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components/log-details/log-details' import { LogsList } from '@/app/workspace/[workspaceId]/logs/components/logs-list' @@ -13,6 +14,8 @@ import { type MarketProviderParamDefinition, } from '@/providers/market/providers' import type { WorkflowLog } from '@/stores/logs/filters/types' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { useListingSelectorStore } from '@/stores/market/selector/store' import { loadIndicatorOptions, loadMonitors, loadWorkflowTargetOptions } from './api' import { MonitorEditorModal } from './monitor-editor-modal' @@ -49,6 +52,9 @@ export function MonitorsView({ onExportContextChange, onRefreshingChange, }: MonitorsViewProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.logs.monitors + const isAuthParamDefinition = useCallback((definition: MarketProviderParamDefinition) => { if (definition.password) return true const normalizedId = definition.id.replace(/\s+/g, '').toLowerCase() @@ -143,10 +149,10 @@ export function MonitorsView({ }, [workflowTargets]) const addMonitorDisabledReason = useMemo(() => { - if (referenceLoading) return 'Loading monitor requirements...' + if (referenceLoading) return copy.loadRequirements if (workflowTargets.length > 0 && indicatorOptions.length > 0) return null - return 'No deployed workflow with indicator trigger is available, or no trigger-capable indicator exists.' - }, [referenceLoading, workflowTargets.length, indicatorOptions.length]) + return copy.noDeployedWorkflow + }, [referenceLoading, workflowTargets.length, indicatorOptions.length, copy]) const canAddMonitor = addMonitorDisabledReason === null @@ -170,11 +176,11 @@ export function MonitorsView({ const data = await loadMonitors(workspaceId) setMonitors(data) } catch (error) { - setMonitorsError(error instanceof Error ? error.message : 'Failed to load monitors') + setMonitorsError(error instanceof Error ? error.message : copy.failedToLoad) } finally { setMonitorsLoading(false) } - }, [workspaceId]) + }, [workspaceId, copy.failedToLoad]) useEffect(() => { let cancelled = false @@ -196,7 +202,7 @@ export function MonitorsView({ setWorkflowTargets(nextWorkflowTargets) } catch (error) { if (!cancelled) { - setMonitorsError(error instanceof Error ? error.message : 'Failed to load monitors') + setMonitorsError(error instanceof Error ? error.message : copy.failedToLoad) } } finally { if (!cancelled) { @@ -210,7 +216,7 @@ export function MonitorsView({ return () => { cancelled = true } - }, [workspaceId]) + }, [workspaceId, copy.failedToLoad]) useEffect(() => { if (monitors.length === 0) { @@ -590,7 +596,7 @@ export function MonitorsView({ setEditingDraft(null) setEditingErrors({}) } catch (error) { - setMonitorsError(error instanceof Error ? error.message : 'Failed to save monitor') + setMonitorsError(error instanceof Error ? error.message : copy.failedToSave) } finally { setSaving(false) } @@ -608,7 +614,7 @@ export function MonitorsView({ const nextIsActive = !monitor.isActive const target = workflowTargetByKey.get(`${monitor.workflowId}:${monitor.blockId}`) if (nextIsActive && target?.isDeployed !== true) { - setMonitorsError('Activate is disabled because this monitor workflow is not deployed.') + setMonitorsError(copy.activateDisabled) return } @@ -647,12 +653,12 @@ export function MonitorsView({ entry.monitorId === monitor.monitorId ? { ...entry, isActive: !nextIsActive } : entry ) ) - setMonitorsError(error instanceof Error ? error.message : 'Failed to update monitor state') + setMonitorsError(error instanceof Error ? error.message : copy.failedToUpdateState) } finally { setTogglingMonitorId(null) } }, - [workspaceId, upsertMonitor, workflowTargetByKey] + [workspaceId, upsertMonitor, workflowTargetByKey, copy.activateDisabled, copy.failedToUpdateState] ) const removeMonitor = useCallback(async (monitorId: string) => { @@ -670,11 +676,11 @@ export function MonitorsView({ setMonitors((current) => current.filter((entry) => entry.monitorId !== monitorId)) } catch (error) { - setMonitorsError(error instanceof Error ? error.message : 'Failed to delete monitor') + setMonitorsError(error instanceof Error ? error.message : copy.failedToDelete) } finally { setDeletingMonitorId(null) } - }, []) + }, [copy.failedToDelete]) const selectMonitor = useCallback((monitorId: string) => { setSelectedMonitorId(monitorId) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/logs.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/logs/logs.tsx index 3776ca93c..f256b0989 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/logs.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Loader2, Plus, RefreshCw, Scroll } from 'lucide-react' -import { useParams } from 'next/navigation' +import { useParams, usePathname } from 'next/navigation' import { Button } from '@/components/ui/button' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -26,6 +26,8 @@ import { useDebounce } from '@/hooks/use-debounce' import { useFolderStore } from '@/stores/folders/store' import { useFilterStore } from '@/stores/logs/filters/store' import type { WorkflowLog } from '@/stores/logs/filters/types' +import { getPublicCopy } from '@/i18n/public-copy' +import { stripLocaleFromPathname } from '@/i18n/utils' const LOGS_PER_PAGE = 50 @@ -43,7 +45,10 @@ const selectedRowAnimation = ` export default function Logs() { const params = useParams() + const pathname = usePathname() const workspaceId = params.workspaceId as string + const locale = stripLocaleFromPathname(pathname ?? '/').locale + const logsCopy = getPublicCopy(locale).workspace.logs const { setWorkspaceId, @@ -85,7 +90,7 @@ export default function Logs() { reason: string | null }>({ canAdd: false, - reason: 'Loading monitor requirements...', + reason: logsCopy.monitors.loadRequirements, }) const [availableWorkflows, setAvailableWorkflows] = useState<string[]>([]) @@ -129,7 +134,7 @@ export default function Logs() { logsQuery.error instanceof Error ? logsQuery.error.message : logsQuery.error - ? 'Failed to fetch logs' + ? logsCopy.errors.fetchLogs : null const detailedSelectedLog = logDetailQuery.data ?? selectedLog @@ -404,21 +409,22 @@ export default function Logs() { ? !monitorAddState.canAdd || monitorsAddHandlerRef.current === null : false const addMonitorTooltip = isAddMonitorDisabled - ? monitorAddState.reason || - 'Please configure workflow that uses indicator as trigger and indicator that emits trigger to add monitor' - : 'Add monitor' + ? monitorAddState.reason || logsCopy.monitors.loadRequirements + : logsCopy.actions.addMonitor const headerLeftContent = isDashboardView ? null : ( <div className='flex w-full flex-1 items-center gap-3'> <div className='hidden items-center gap-2 sm:flex'> <Scroll className='h-[18px] w-[18px] text-muted-foreground' /> - <span className='font-medium text-sm'>{isMonitorsView ? 'Monitors' : 'Logs'}</span> + <span className='font-medium text-sm'> + {isMonitorsView ? logsCopy.title.monitors : logsCopy.title.logs} + </span> </div> <div className='flex w-full flex-1'> <AutocompleteSearch value={searchQuery} onChange={setSearchQuery} - placeholder='Search logs...' + placeholder={logsCopy.searchPlaceholder} availableWorkflows={availableWorkflows} availableFolders={availableFolders} className='w-full' @@ -447,7 +453,7 @@ export default function Logs() { )} aria-pressed={isLive} > - Live + {logsCopy.live} </Button> </div> @@ -464,7 +470,7 @@ export default function Logs() { )} aria-pressed={viewMode === 'logs'} > - Logs + {logsCopy.title.logs} </Button> <Button variant='ghost' @@ -478,7 +484,7 @@ export default function Logs() { )} aria-pressed={viewMode === 'monitors'} > - Monitors + {logsCopy.title.monitors} </Button> <Button variant='ghost' @@ -490,7 +496,7 @@ export default function Logs() { )} aria-pressed={false} > - Dashboard + {logsCopy.title.dashboard} </Button> </div> </div> @@ -510,11 +516,11 @@ export default function Logs() { monitorsAddHandlerRef.current?.() }} className='h-9 rounded-md hover:bg-secondary' - aria-label='Add monitor' + aria-label={logsCopy.actions.addMonitor} disabled={isAddMonitorDisabled} > <Plus className='h-5 w-5' /> - <span className='sr-only'>Add monitor</span> + <span className='sr-only'>{logsCopy.actions.addMonitor}</span> </Button> </span> </TooltipTrigger> @@ -536,10 +542,12 @@ export default function Logs() { ) : ( <RefreshCw className='h-5 w-5' /> )} - <span className='sr-only'>Refresh</span> + <span className='sr-only'>{logsCopy.actions.refresh}</span> </Button> </TooltipTrigger> - <TooltipContent>{isRefreshing ? 'Refreshing...' : 'Refresh'}</TooltipContent> + <TooltipContent> + {isRefreshing ? logsCopy.actions.refreshing : logsCopy.actions.refresh} + </TooltipContent> </Tooltip> <Tooltip> @@ -547,10 +555,10 @@ export default function Logs() { <Button variant='ghost' size='icon' - onClick={handleExport} - className='h-9 rounded-md hover:bg-secondary' - aria-label='Export CSV' - > + onClick={handleExport} + className='h-9 rounded-md hover:bg-secondary' + aria-label={logsCopy.actions.exportCsv} + > <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' @@ -563,10 +571,10 @@ export default function Logs() { <polyline points='7 10 12 15 17 10' /> <line x1='12' y1='15' x2='12' y2='3' /> </svg> - <span className='sr-only'>Export CSV</span> + <span className='sr-only'>{logsCopy.actions.exportCsv}</span> </Button> </TooltipTrigger> - <TooltipContent>Export CSV</TooltipContent> + <TooltipContent>{logsCopy.actions.exportCsv}</TooltipContent> </Tooltip> </div> ) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/logs/utils.ts b/apps/tradinggoose/app/workspace/[workspaceId]/logs/utils.ts index 3a33e7bd9..3d017318a 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/logs/utils.ts @@ -286,10 +286,11 @@ export function scaleLogCostBreakdown< } } -export const formatDate = (dateString: string) => { +export const formatDate = (dateString: string, locale = 'en-US') => { const date = new Date(dateString) + const relativeFormat = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) return { - full: date.toLocaleDateString('en-US', { + full: date.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', @@ -298,32 +299,32 @@ export const formatDate = (dateString: string) => { second: '2-digit', hour12: false, }), - time: date.toLocaleTimeString([], { + time: date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }), formatted: format(date, 'HH:mm:ss'), - compact: format(date, 'MMM d HH:mm:ss'), - compactDate: format(date, 'MMM d').toUpperCase(), + compact: `${date.toLocaleDateString(locale, { month: 'short', day: 'numeric' })} ${format(date, 'HH:mm:ss')}`, + compactDate: date.toLocaleDateString(locale, { month: 'short', day: 'numeric' }).toUpperCase(), compactTime: format(date, 'HH:mm:ss'), relative: (() => { const now = new Date() const diffMs = now.getTime() - date.getTime() const diffMins = Math.floor(diffMs / 60000) - if (diffMins < 1) return 'just now' - if (diffMins < 60) return `${diffMins}m ago` + if (diffMins < 1) return relativeFormat.format(0, 'second') + if (diffMins < 60) return relativeFormat.format(-diffMins, 'minute') const diffHours = Math.floor(diffMins / 60) - if (diffHours < 24) return `${diffHours}h ago` + if (diffHours < 24) return relativeFormat.format(-diffHours, 'hour') const diffDays = Math.floor(diffHours / 24) - if (diffDays === 1) return 'yesterday' - if (diffDays < 7) return `${diffDays}d ago` + if (diffDays === 1) return relativeFormat.format(-1, 'day') + if (diffDays < 7) return relativeFormat.format(-diffDays, 'day') - return format(date, 'MMM d') + return date.toLocaleDateString(locale, { month: 'short', day: 'numeric' }) })(), } } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/page.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/page.tsx index e76193a95..111feed9e 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/page.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/page.tsx @@ -1,4 +1,6 @@ +import { headers } from 'next/headers' import { redirect } from 'next/navigation' +import { defaultLocale, isLocaleCode, localizeHref, type LocaleCode } from '@/i18n/utils' export default async function WorkspacePage({ params, @@ -6,5 +8,8 @@ export default async function WorkspacePage({ params: Promise<{ workspaceId: string }> }) { const { workspaceId } = await params - redirect(`/workspace/${workspaceId}/dashboard`) + const requestHeaders = await headers() + const resolvedLocale = requestHeaders.get('x-next-intl-locale') ?? '' + const locale: LocaleCode = isLocaleCode(resolvedLocale) ? resolvedLocale : defaultLocale + redirect(localizeHref(locale, `/workspace/${workspaceId}/dashboard`)) } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.test.tsx index ab312e111..18febb369 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.test.tsx @@ -9,6 +9,7 @@ const mockUseWorkspacePermissions = vi.fn() const mockUseUserPermissions = vi.fn() const mockUpdatePermissions = vi.fn() const mockRefetchPermissions = vi.fn() +let mockPathname = '/zh/workspace/ws-1/dashboard' const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean @@ -17,6 +18,7 @@ const previousActEnvironment = reactActEnvironment.IS_REACT_ACT_ENVIRONMENT vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'ws-1' }), + usePathname: () => mockPathname, useRouter: () => ({ replace: mockReplace, }), @@ -48,7 +50,8 @@ describe('WorkspacePermissionsProvider', () => { beforeEach(() => { vi.clearAllMocks() - window.history.replaceState({}, '', '/workspace/ws-1/dashboard?layoutId=layout-1') + mockPathname = '/zh/workspace/ws-1/dashboard' + window.history.replaceState({}, '', '/zh/workspace/ws-1/dashboard?layoutId=layout-1') mockUseWorkspacePermissions.mockReturnValue({ permissions: null, @@ -117,7 +120,7 @@ describe('WorkspacePermissionsProvider', () => { }) expect(mockReplace).toHaveBeenCalledWith( - '/login?reauth=1&callbackUrl=%2Fworkspace%2Fws-1%2Fdashboard%3FlayoutId%3Dlayout-1' + '/zh/login?reauth=1&callbackUrl=%2Fzh%2Fworkspace%2Fws-1%2Fdashboard%3FlayoutId%3Dlayout-1' ) expect(container?.textContent).toBe('') }) @@ -150,7 +153,7 @@ describe('WorkspacePermissionsProvider', () => { ) }) - expect(mockReplace).toHaveBeenCalledWith('/workspace') + expect(mockReplace).toHaveBeenCalledWith('/zh/workspace') expect(container?.textContent).toBe('') }) }) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx index e3d747d06..a6d0b0003 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx @@ -2,13 +2,14 @@ import type React from 'react' import { createContext, useContext, useEffect, useMemo, useState } from 'react' -import { useParams, useRouter } from 'next/navigation' +import { useParams, usePathname, useRouter } from 'next/navigation' import { createLogger } from '@/lib/logs/console/logger' import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions' import { useWorkspacePermissions, type WorkspacePermissions, } from '@/hooks/use-workspace-permissions' +import { localizeHref, stripLocaleFromPathname } from '@/i18n/utils' const logger = createLogger('WorkspacePermissionsProvider') const ACCESS_DENIED_PATTERNS = ['access denied', 'workspace not found', 'user not found'] @@ -60,8 +61,10 @@ export function WorkspacePermissionsProvider({ workspaceId: workspaceIdProp, }: WorkspacePermissionsProviderProps) { const params = useParams() + const pathname = usePathname() const router = useRouter() const workspaceId = workspaceIdProp ?? (params?.workspaceId as string | undefined) ?? null + const locale = stripLocaleFromPathname(pathname ?? '/').locale // Manage offline mode state locally const [isOfflineMode, setIsOfflineMode] = useState(false) @@ -149,17 +152,19 @@ export function WorkspacePermissionsProvider({ } if (isAuthError) { - const callbackTarget = - typeof window === 'undefined' - ? `/workspace/${workspaceId}/dashboard` - : `${window.location.pathname}${window.location.search}` + const callbackTarget = `${pathname ?? '/'}${window.location.search}` setHasRedirected(true) logger.warn('Redirecting unauthenticated user from protected workspace route', { workspaceId, error: combinedError ?? 'missing session', }) - router.replace(`/login?reauth=1&callbackUrl=${encodeURIComponent(callbackTarget)}`) + router.replace( + localizeHref( + locale, + `/login?reauth=1&callbackUrl=${encodeURIComponent(callbackTarget)}` + ) + ) return } @@ -168,8 +173,8 @@ export function WorkspacePermissionsProvider({ workspaceId, error: combinedError ?? 'missing read permissions', }) - router.replace('/workspace') - }, [combinedError, hasRedirected, isAuthError, router, shouldTriggerRedirect, workspaceId]) + router.replace(localizeHref(locale, '/workspace')) + }, [combinedError, hasRedirected, isAuthError, locale, pathname, router, shouldTriggerRedirect, workspaceId]) const shouldBlockRender = hasRedirected || shouldTriggerRedirect diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx index 21ca441dc..d8a76c05e 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx @@ -46,9 +46,11 @@ import { Zap, } from 'lucide-react' import { useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' +import { localizeHref, type LocaleCode } from '@/i18n/utils' import type { Template } from '@/app/workspace/[workspaceId]/templates/templates' import { categories } from '@/app/workspace/[workspaceId]/templates/templates' import { WorkflowPreview } from '@/app/workspace/[workspaceId]/components/workflow-preview/workflow-preview' @@ -122,6 +124,7 @@ export default function TemplateDetails({ currentUserId, }: TemplateDetailsProps) { const router = useRouter() + const locale = useLocale() as LocaleCode const [isStarred, setIsStarred] = useState(template?.isStarred || false) const [starCount, setStarCount] = useState(template?.stars || 0) const [isStarring, setIsStarring] = useState(false) @@ -239,7 +242,7 @@ export default function TemplateDetails({ const newWorkflow = await response.json() // Navigate to the new workflow - router.push(`/workspace/${workspaceId}/dashboard`) + router.push(localizeHref(locale, `/workspace/${workspaceId}/dashboard`)) } catch (error) { logger.error('Error using template:', error) // Show error to user (could implement toast notification) diff --git a/apps/tradinggoose/app/workspace/page.test.tsx b/apps/tradinggoose/app/workspace/page.test.tsx new file mode 100644 index 000000000..aef2d767a --- /dev/null +++ b/apps/tradinggoose/app/workspace/page.test.tsx @@ -0,0 +1,147 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' + +const mockReplace = vi.fn() +const mockUseSession = vi.fn() +const mockPathname = vi.fn() +let fetchMock: ReturnType<typeof vi.fn> + +vi.mock('next/navigation', () => ({ + usePathname: () => mockPathname(), + useRouter: () => ({ + replace: mockReplace, + }), +})) + +vi.mock('@/lib/auth-client', () => ({ + useSession: (...args: unknown[]) => mockUseSession(...args), +})) + +vi.mock('@/components/ui/loading-agent', () => ({ + LoadingAgent: () => <svg data-testid='loading-agent' />, +})) + +describe('WorkspacePage', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mockReplace.mockReset() + mockPathname.mockReturnValue('/zh/workspace') + window.history.replaceState({}, '', '/zh/workspace') + mockUseSession.mockReturnValue({ + data: { + user: { + id: 'user-1', + }, + }, + isPending: false, + error: null, + }) + + fetchMock = vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === '/api/workspaces') { + return { + ok: true, + json: async () => ({ + workspaces: [{ id: 'ws-1', name: 'Workspace 1' }], + }), + } as Response + } + + throw new Error(`Unexpected fetch request: ${String(input)}`) + }) + + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(async () => { + await act(async () => { + root.unmount() + }) + container.remove() + vi.unstubAllGlobals() + }) + + it('redirects locale-prefixed workspace root visits to the localized workspace dashboard', async () => { + const WorkspacePage = (await import('./page')).default + + await act(async () => { + root.render(<WorkspacePage />) + }) + + expect(container.querySelector('[data-testid="loading-agent"]')).not.toBeNull() + const workspacesCall = fetchMock.mock.calls.find(([url]) => String(url) === '/api/workspaces') + expect(workspacesCall).toBeDefined() + const requestInit = workspacesCall?.[1] as RequestInit | undefined + expect(new Headers(requestInit?.headers).get('x-next-intl-locale')).toBe('zh-CN') + expect(mockReplace).toHaveBeenCalledWith('/zh/workspace/ws-1/dashboard') + }) + + it('continues to fetch workspaces when a localized callbackUrl matches the current pathname', async () => { + window.history.replaceState({}, '', '/zh/workspace?callbackUrl=/workspace') + + const WorkspacePage = (await import('./page')).default + + await act(async () => { + root.render(<WorkspacePage />) + }) + + const workspacesCall = fetchMock.mock.calls.find(([url]) => String(url) === '/api/workspaces') + expect(workspacesCall).toBeDefined() + expect(mockReplace).toHaveBeenCalledWith('/zh/workspace/ws-1/dashboard') + }) + + it('creates a localized default workspace on first run', async () => { + const copy = getPublicCopy('zh-CN') + + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === '/api/workspaces' && (!init?.method || init.method === 'GET')) { + return { + ok: true, + json: async () => ({ + workspaces: [], + }), + } as Response + } + + if (String(input) === '/api/workspaces' && init?.method === 'POST') { + expect(new Headers(init.headers as HeadersInit).get('x-next-intl-locale')).toBe('zh-CN') + expect(JSON.parse(String(init.body))).toEqual({ + name: copy.workspace.defaults.newWorkspaceName, + }) + + return { + ok: true, + json: async () => ({ + workspace: { id: 'ws-2' }, + }), + } as Response + } + + throw new Error(`Unexpected fetch request: ${String(input)}`) + }) + + const WorkspacePage = (await import('./page')).default + + await act(async () => { + root.render(<WorkspacePage />) + }) + + expect(container.querySelector('[data-testid="loading-agent"]')).not.toBeNull() + const postCall = fetchMock.mock.calls.find( + ([url, init]) => String(url) === '/api/workspaces' && (init as RequestInit | undefined)?.method === 'POST' + ) + expect(postCall).toBeDefined() + expect(mockReplace).toHaveBeenCalledWith('/zh/workspace/ws-2/dashboard') + }) +}) diff --git a/apps/tradinggoose/app/workspace/page.tsx b/apps/tradinggoose/app/workspace/page.tsx index 8e6636c5e..f029996d1 100644 --- a/apps/tradinggoose/app/workspace/page.tsx +++ b/apps/tradinggoose/app/workspace/page.tsx @@ -1,17 +1,21 @@ 'use client' import { useEffect } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import { LoadingAgent } from '@/components/ui/loading-agent' import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' +import { getPublicCopy } from '@/i18n/public-copy' +import { buildLocaleRequestHeaders, localizeHref, stripLocaleFromPathname } from '@/i18n/utils' const logger = createLogger('WorkspacePage') export default function WorkspacePage() { const router = useRouter() - const searchParams = useSearchParams() + const pathname = usePathname() const { data: session, isPending, error: sessionError } = useSession() + const locale = stripLocaleFromPathname(pathname ?? '/').locale + const workspaceCopy = getPublicCopy(locale).workspace useEffect(() => { const redirectToFirstWorkspace = async () => { @@ -25,7 +29,7 @@ export default function WorkspacePage() { logger.info('User not authenticated, redirecting to home', { hasSessionError: Boolean(sessionError), }) - router.replace('/') + router.replace(localizeHref(locale, '/')) return } @@ -38,11 +42,16 @@ export default function WorkspacePage() { if ( callbackUrl?.startsWith('/') && !callbackUrl.startsWith('//') && - callbackUrl !== window.location.pathname + callbackUrl !== pathname ) { - logger.info('Redirecting to callback URL from workspace root', { callbackUrl }) - router.replace(callbackUrl) - return + const localizedCallbackUrl = localizeHref(locale, callbackUrl) + const localizedCallbackPath = new URL(localizedCallbackUrl, window.location.origin).pathname + + if (localizedCallbackPath !== pathname) { + logger.info('Redirecting to callback URL from workspace root', { callbackUrl }) + router.replace(localizedCallbackUrl) + return + } } if (redirectWorkflowId) { @@ -57,7 +66,7 @@ export default function WorkspacePage() { logger.info( `Redirecting workflow ${redirectWorkflowId} to workspace ${workspaceId} dashboard` ) - router.replace(`/workspace/${workspaceId}/dashboard`) + router.replace(localizeHref(locale, `/workspace/${workspaceId}/dashboard`)) return } } @@ -69,13 +78,14 @@ export default function WorkspacePage() { // Fetch user's workspaces const response = await fetch('/api/workspaces', { credentials: 'include', + headers: buildLocaleRequestHeaders(locale), }) if (response.status === 401 || response.status === 403) { logger.info('Unauthorized to fetch workspaces, redirecting to home', { status: response.status, }) - router.replace('/') + router.replace(localizeHref(locale, '/')) return } @@ -89,7 +99,7 @@ export default function WorkspacePage() { status: response.status, body: errorBody, }) - router.replace('/') + router.replace(localizeHref(locale, '/')) return } @@ -102,10 +112,10 @@ export default function WorkspacePage() { try { const createResponse = await fetch('/api/workspaces', { method: 'POST', - headers: { + headers: buildLocaleRequestHeaders(locale, { 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name: 'My Workspace' }), + }), + body: JSON.stringify({ name: workspaceCopy.defaults.newWorkspaceName }), }) if (createResponse.ok) { @@ -116,7 +126,7 @@ export default function WorkspacePage() { logger.info( `Created default workspace ${newWorkspace.id}, redirecting to dashboard` ) - router.replace(`/workspace/${newWorkspace.id}/dashboard`) + router.replace(localizeHref(locale, `/workspace/${newWorkspace.id}/dashboard`)) return } } @@ -127,7 +137,7 @@ export default function WorkspacePage() { } // If we can't create a workspace, redirect home to reset state - router.replace('/') + router.replace(localizeHref(locale, '/')) return } @@ -136,39 +146,23 @@ export default function WorkspacePage() { logger.info(`Redirecting to workspace ${firstWorkspace.id} dashboard`) // Redirect to the first workspace - router.replace(`/workspace/${firstWorkspace.id}/dashboard`) + router.replace(localizeHref(locale, `/workspace/${firstWorkspace.id}/dashboard`)) } catch (error) { logger.error('Error fetching workspaces for redirect:', error) // Any unexpected error should send the user home. - router.replace('/') - } - } - - // Only run this logic when we're at the root /workspace path - // If we're already in a specific workspace, the children components will handle it - if (typeof window !== 'undefined') { - const normalizedPath = window.location.pathname.replace(/\/+$/, '') || '/' - if (normalizedPath === '/workspace') { - redirectToFirstWorkspace() + router.replace(localizeHref(locale, '/')) } } - }, [session, isPending, sessionError, router, searchParams]) - - // Show loading state while we determine where to redirect - if (isPending) { - return ( - <div className='flex h-screen w-full items-center justify-center'> - <div className='flex flex-col items-center justify-center text-center align-middle'> - <LoadingAgent size='lg' /> - </div> - </div> - ) - } - // If user is not authenticated, show nothing (redirect will happen) - if (sessionError || !session?.user) { - return null - } + void redirectToFirstWorkspace() + }, [locale, pathname, router, session, isPending, sessionError]) - return null + return ( + <div className='flex h-screen w-full items-center justify-center'> + <div className='flex flex-col items-center justify-center text-center align-middle'> + <LoadingAgent size='lg' /> + <span className='sr-only'>{workspaceCopy.entry.loading}</span> + </div> + </div> + ) } diff --git a/apps/tradinggoose/blocks/blocks/trading_action.ts b/apps/tradinggoose/blocks/blocks/trading_action.ts index f6b85aceb..2241c7e73 100644 --- a/apps/tradinggoose/blocks/blocks/trading_action.ts +++ b/apps/tradinggoose/blocks/blocks/trading_action.ts @@ -1,12 +1,12 @@ import { DollarIcon } from '@/components/icons/icons' +import type { ListingInputValue } from '@/lib/listing/identity' import type { BlockConfig, SubBlockCondition, SubBlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { buildInputsFromToolParams } from '@/blocks/utils' -import type { ListingInputValue } from '@/lib/listing/identity' import { + getTradingProviderIdsForParam, getTradingProviderParamCatalog, getTradingProviderParamDefinitions, - getTradingProviderIdsForParam, getTradingProviders, } from '@/providers/trading' import { getTradingOrderTypeOptions } from '@/providers/trading/order-types' @@ -185,8 +185,7 @@ const providerParamBlocks = (): SubBlockConfig[] => const inputType = resolveParamInputType(definition) const numericInputType = - (inputType === 'short-input' || inputType === 'long-input') && - definition.type === 'number' + (inputType === 'short-input' || inputType === 'long-input') && definition.type === 'number' ? 'number' : undefined const providerCondition = entry.providers.length @@ -225,6 +224,9 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { .filter((provider) => provider.authType === 'oauth' && provider.oauth) .map((provider) => { const oauth = provider.oauth! + const serviceIds = oauth.credentialServices?.length + ? oauth.credentialServices.map((service) => service.serviceId) + : [oauth.serviceId || oauth.provider] return { id: `${provider.id}Credential`, title: oauth.credentialTitle || `${provider.name} Account`, @@ -232,7 +234,7 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { layout: 'full', required: true, provider: oauth.provider, - serviceId: oauth.serviceId || oauth.provider, + ...(serviceIds.length === 1 ? { serviceId: serviceIds[0] } : { serviceIds }), requiredScopes: oauth.scopes || [], placeholder: oauth.credentialPlaceholder || `Select or connect ${provider.name} account`, condition: { field: 'provider', value: provider.id }, @@ -279,7 +281,7 @@ export const TradingActionBlock: BlockConfig<TradingActionResponse> = { }, ...providerCredentialBlocks(), - + { id: 'side', title: 'Action', diff --git a/apps/tradinggoose/blocks/blocks/trading_holdings.ts b/apps/tradinggoose/blocks/blocks/trading_holdings.ts index d146c2555..b89a5a5be 100644 --- a/apps/tradinggoose/blocks/blocks/trading_holdings.ts +++ b/apps/tradinggoose/blocks/blocks/trading_holdings.ts @@ -43,6 +43,9 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { .filter((provider) => provider.authType === 'oauth' && provider.oauth) .map((provider) => { const oauth = provider.oauth! + const serviceIds = oauth.credentialServices?.length + ? oauth.credentialServices.map((service) => service.serviceId) + : [oauth.serviceId || oauth.provider] return { id: `${provider.id}Credential`, title: oauth.credentialTitle || `${provider.name} Account`, @@ -50,7 +53,7 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { layout: 'full', required: true, provider: oauth.provider, - serviceId: oauth.serviceId || oauth.provider, + ...(serviceIds.length === 1 ? { serviceId: serviceIds[0] } : { serviceIds }), requiredScopes: oauth.scopes || [], placeholder: oauth.credentialPlaceholder || `Select or connect ${provider.name} account`, condition: { field: 'provider', value: provider.id }, @@ -64,8 +67,7 @@ export const TradingHoldingsBlock: BlockConfig<TradingHoldingsResponse> = { name: 'Trading Holdings', description: 'Fetch a unified account snapshot from supported brokers.', authMode: AuthMode.OAuth, - longDescription: - 'Unified holdings block that returns an account snapshot for Alpaca or Tradier.', + longDescription: 'Unified holdings block that returns an account snapshot for Alpaca or Tradier.', category: 'tools', bgColor: '#115e59', icon: DollarIcon, @@ -114,13 +116,16 @@ export const TradingHoldingsBlock: BlockConfig<TradingHoldingsResponse> = { .find((value) => value !== undefined) } const credential = resolveCredential() - const extraFields = getProviderFields(provider, 'holdings').reduce((acc, field) => { - const key = `${provider}_${field.id}` - if (params[key] !== undefined) { - acc[field.id] = params[key] - } - return acc - }, {} as Record<string, any>) + const extraFields = getProviderFields(provider, 'holdings').reduce( + (acc, field) => { + const key = `${provider}_${field.id}` + if (params[key] !== undefined) { + acc[field.id] = params[key] + } + return acc + }, + {} as Record<string, any> + ) return { provider, diff --git a/apps/tradinggoose/blocks/blocks/trading_order_detail.ts b/apps/tradinggoose/blocks/blocks/trading_order_detail.ts index 480d2552e..876c3f937 100644 --- a/apps/tradinggoose/blocks/blocks/trading_order_detail.ts +++ b/apps/tradinggoose/blocks/blocks/trading_order_detail.ts @@ -19,6 +19,9 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { .filter((provider) => provider.authType === 'oauth' && provider.oauth) .map((provider) => { const oauth = provider.oauth! + const serviceIds = oauth.credentialServices?.length + ? oauth.credentialServices.map((service) => service.serviceId) + : [oauth.serviceId || oauth.provider] return { id: `${provider.id}Credential`, title: oauth.credentialTitle || `${provider.name} Account`, @@ -26,7 +29,7 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { layout: 'full', required: true, provider: oauth.provider, - serviceId: oauth.serviceId || oauth.provider, + ...(serviceIds.length === 1 ? { serviceId: serviceIds[0] } : { serviceIds }), requiredScopes: oauth.scopes || [], placeholder: oauth.credentialPlaceholder || `Select or connect ${provider.name} account`, condition: { field: 'provider', value: provider.id }, diff --git a/apps/tradinggoose/blocks/types.ts b/apps/tradinggoose/blocks/types.ts index b759b0f32..619941537 100644 --- a/apps/tradinggoose/blocks/types.ts +++ b/apps/tradinggoose/blocks/types.ts @@ -232,6 +232,7 @@ export interface SubBlockConfig { // OAuth specific properties provider?: string serviceId?: string + serviceIds?: string[] requiredScopes?: string[] supportsCredentialSets?: boolean // File selector specific properties diff --git a/apps/tradinggoose/components/icons/provider-icons.tsx b/apps/tradinggoose/components/icons/provider-icons.tsx index ad1388537..961a22e39 100644 --- a/apps/tradinggoose/components/icons/provider-icons.tsx +++ b/apps/tradinggoose/components/icons/provider-icons.tsx @@ -314,11 +314,13 @@ export function YahooIcon(props: SVGProps<SVGSVGElement>) { export function AlphaVantageIcon(props: SVGProps<SVGSVGElement>) { return ( - <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" - strokeWidth='2' - strokeLinecap='round' - strokeLinejoin='round'> - <path fill="#40c0e7" d="M115.37 117.77L77.78 17.81a2.24 2.24 0 0 0-2.1-1.45H52.32c-.94 0-1.77.58-2.1 1.45l-37.59 99.96c-.26.69-.17 1.46.25 2.06s1.1.97 1.84.97h24.64c.96 0 1.82-.62 2.13-1.54l5.7-17.18H80.8l5.71 17.18c.3.92 1.16 1.54 2.13 1.54h24.64a2.236 2.236 0 0 0 2.09-3.03m-61.14-36.9L64 51.45l9.77 29.43H54.23z" /> + <svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 512 238" preserveAspectRatio="xMidYMid meet"> + <g fill="currentColor"> + <path d="M 327.1 228.9 c -5.9 -1.1 -17.5 -6.7 -23.4 -11.2 -6.7 -5.1 -9.7 -8.8 -34.8 -42.7 -5.5 -7.4 -10.9 -14.6 -11.9 -16 -5.2 -6.8 -15.7 -20.8 -17 -22.7 -0.8 -1.2 -4.9 -6.6 -9 -11.9 -4.1 -5.4 -8.2 -10.8 -9 -12 -0.8 -1.2 -5.7 -7.8 -10.8 -14.6 -5.1 -6.7 -10.2 -13.4 -11.2 -14.8 -15.7 -21.2 -19.1 -25 -25.2 -28.1 -4.7 -2.4 -7.2 -2.4 -12.4 0.1 -5.8 2.9 -10 7.2 -20.4 21.3 -9.3 12.5 -21.2 28.3 -23 30.4 -0.5 0.6 -2.2 3 -3.7 5.1 -1.6 2.2 -6.2 8.4 -10.3 13.8 -4.1 5.4 -8.2 10.8 -9 12 -1.8 2.7 -34.5 46.1 -47.1 62.7 -4.9 6.4 -9.2 12.3 -9.5 13 -0.2 0.7 -2.9 2.7 -5.8 4.5 -4.2 2.5 -6.5 3.2 -10.5 3.2 -8.3 0 -16.4 -5.2 -19.7 -12.7 -2.8 -6.5 -1.2 -18.8 3.2 -23.2 0.8 -0.9 4.9 -6.2 9.2 -11.8 11.3 -14.9 29.5 -39.2 31.2 -41.6 1.8 -2.5 14.2 -19.2 16.8 -22.4 0.9 -1.2 4.9 -6.5 8.7 -11.7 3.9 -5.3 11.1 -15.1 16.2 -21.9 5.1 -6.7 10.4 -13.8 11.8 -15.7 1.4 -1.9 5.2 -6.9 8.3 -11 3.1 -4.1 7.3 -9.7 9.2 -12.5 5.4 -7.7 16.2 -17.9 21.6 -20.4 2.7 -1.3 6.3 -2.9 7.9 -3.7 9.3 -4.3 32.1 -4.5 41 -0.4 1.1 0.5 4.1 1.8 6.7 2.8 9.2 3.9 18.4 12.2 26.8 24.2 1.3 1.9 2.8 4 3.2 4.5 3.2 4.1 49.9 66.1 51.8 68.8 1.5 2.2 18.5 25.1 22 29.7 1.1 1.4 5.3 7 9.4 12.5 16.3 21.8 18.4 24.3 22.8 27.5 7.3 5.3 14.4 4.9 21.6 -1.4 3 -2.6 12 -13.6 21.8 -26.6 20.4 -27.3 32.2 -43.2 34.3 -46.1 1.4 -2 11.4 -15.2 22.1 -29.4 10.7 -14.1 20.2 -26.6 21 -27.8 1.3 -1.9 8.5 -11.7 17.7 -23.9 3.3 -4.4 9.7 -7.9 15.8 -8.6 6 -0.6 14 3.2 18.5 8.8 3 3.8 3.5 5.2 3.8 11.2 0.4 6.5 0.2 7.2 -3.3 13.1 -2.1 3.4 -4.5 6.9 -5.2 7.7 -1.2 1.2 -50.3 66.7 -57 75.9 -1 1.4 -4.9 6.6 -8.8 11.7 -13.5 17.9 -29 38.6 -30.5 40.7 -9.7 14 -20.5 26.8 -26.5 31.5 -4.4 3.5 -16.2 8.8 -22.3 10.1 -6.1 1.3 -20.6 1.3 -27.1 0 z " /> + </g> + <g fill="#31bc80"> + <path d="M196.6 171.3 C 196.6 186.8 184.1 199.3 168.7 199.3 153.3 199.3 140.8 186.8 140.8 171.3 140.8 155.9 153.3 143.4 168.7 143.4 184.1 143.4 196.6 155.9 196.6 171.3 Z M158.7 197.6 " /> + </g> </svg> ) } diff --git a/apps/tradinggoose/components/listing-selector/selector/resolve-request.ts b/apps/tradinggoose/components/listing-selector/selector/resolve-request.ts index d407c242a..a6d507eee 100644 --- a/apps/tradinggoose/components/listing-selector/selector/resolve-request.ts +++ b/apps/tradinggoose/components/listing-selector/selector/resolve-request.ts @@ -1,305 +1,11 @@ -import { - type ListingIdentity, - type ListingResolved, -} from '@/lib/listing/identity' -import { MARKET_API_VERSION } from '@/lib/market/client/constants' +import type { ListingIdentity, ListingResolved } from '@/lib/listing/identity' +import { resolveListingIdentity, type ResolvedListingDetails } from '@/lib/listing/resolve' -export type ResolvedListingDetails = { - base?: string - quote?: string | null - name?: string | null - iconUrl?: string | null - assetClass?: string | null - base_asset_class?: string | null - quote_asset_class?: string | null - marketCode?: string | null - countryCode?: string | null - cityName?: string | null - timeZoneName?: string | null -} - -type MarketSearchResponse<T> = { - data?: T - error?: string -} - -type CodeRow = { code?: string; name?: string | null; iconUrl?: string | null } - -const uniqueNonEmpty = (values: string[]) => { - const seen = new Set<string>() - const result: string[] = [] - for (const value of values) { - const trimmed = value.trim() - if (!trimmed || seen.has(trimmed)) continue - seen.add(trimmed) - result.push(trimmed) - } - return result -} - -const fetchMarketSearch = async <T>( - path: string, - params: URLSearchParams, - signal?: AbortSignal -): Promise<T | null> => { - if (!params.get('version')) { - params.set('version', MARKET_API_VERSION) - } - - const response = await fetch(`/api/market/get/${path}?${params.toString()}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - signal, - }) - - let payload: MarketSearchResponse<T> | null = null - try { - payload = (await response.json()) as MarketSearchResponse<T> - } catch { - payload = null - } - - if (!response.ok) { - throw new Error(payload?.error || `Market search failed: ${path}`) - } - if (!payload) return null - if (payload.error) { - throw new Error(payload.error) - } - return payload.data ?? null -} - -const fetchMarketBatch = async <T>( - path: string, - paramName: string, - ids: string[], - signal?: AbortSignal -): Promise<Record<string, T | null>> => { - const uniqueIds = uniqueNonEmpty(ids) - const result: Record<string, T | null> = {} - if (!uniqueIds.length) return result - - const params = new URLSearchParams() - uniqueIds.forEach((id) => params.append(paramName, id)) - const data = await fetchMarketSearch<any>(path, params, signal) - - if (!data) { - uniqueIds.forEach((id) => { - result[id] = null - }) - return result - } - - if (uniqueIds.length === 1) { - const single = data && typeof data === 'object' ? (data as T) : null - result[uniqueIds[0]] = single - return result - } - - if (typeof data !== 'object' || Array.isArray(data)) { - uniqueIds.forEach((id) => { - result[id] = null - }) - return result - } - - const record = data as Record<string, unknown> - uniqueIds.forEach((id) => { - const value = record[id] - result[id] = value && typeof value === 'object' ? (value as T) : null - }) - return result -} - -const toCodeRow = (row: unknown): CodeRow | null => { - if (!row || typeof row !== 'object') return null - const record = row as CodeRow - return { code: record.code, name: record.name ?? null, iconUrl: record.iconUrl ?? null } -} - -const getBatchRow = async <T>( - path: string, - paramName: string, - id: string, - signal?: AbortSignal -): Promise<T | null> => { - const records = await fetchMarketBatch<T>(path, paramName, [id], signal) - return records[id] ?? null -} - -const resolveListingById = async ( - listingId: string, - signal?: AbortSignal -): Promise<ResolvedListingDetails | null> => { - const listing = await getBatchRow<any>('listing', 'listing_id', listingId, signal) - if (!listing || typeof listing !== 'object') return null - return { - base: listing.base, - quote: listing.quote ?? null, - name: listing.name ?? null, - iconUrl: listing.iconUrl ?? null, - assetClass: listing.assetClass ?? null, - marketCode: listing.marketCode ?? null, - countryCode: listing.countryCode ?? null, - cityName: listing.cityName ?? null, - timeZoneName: listing.timeZoneName ?? null, - } -} - -const resolveCurrencyById = async ( - currencyId: string, - signal?: AbortSignal -): Promise<{ code?: string; name?: string | null; iconUrl?: string | null } | null> => { - return toCodeRow(await getBatchRow<any>('currency', 'currency_id', currencyId, signal)) -} - -const resolveCryptoById = async ( - cryptoId: string, - signal?: AbortSignal -): Promise<{ code?: string; name?: string | null; iconUrl?: string | null } | null> => { - return toCodeRow(await getBatchRow<any>('crypto', 'crypto_id', cryptoId, signal)) -} - -const resolveCurrencyPair = async ( - baseId: string, - quoteId: string, - signal?: AbortSignal -): Promise<ResolvedListingDetails | null> => { - const records = await fetchMarketBatch<any>('currency', 'currency_id', [baseId, quoteId], signal) - const baseRow = toCodeRow(records[baseId]) - const quoteRow = toCodeRow(records[quoteId]) - if (!baseRow?.code || !quoteRow?.code) return null - const baseName = baseRow.name?.trim() || baseRow.code - const quoteName = quoteRow.name?.trim() || quoteRow.code - return { - base: baseRow.code, - quote: quoteRow.code, - name: `${baseName} to ${quoteName} pair`, - iconUrl: baseRow.iconUrl ?? null, - assetClass: 'currency', - base_asset_class: 'currency', - quote_asset_class: 'currency', - } -} - -const resolveCryptoPair = async ( - baseId: string, - quoteId: string, - signal?: AbortSignal -): Promise<ResolvedListingDetails | null> => { - const records = await fetchMarketBatch<any>('crypto', 'crypto_id', [baseId, quoteId], signal) - const baseRow = toCodeRow(records[baseId]) - const quoteRow = toCodeRow(records[quoteId]) - if (!baseRow?.code || !quoteRow?.code) return null - const baseName = baseRow.name?.trim() || baseRow.code - const quoteName = quoteRow.name?.trim() || quoteRow.code - return { - base: baseRow.code, - quote: quoteRow.code, - name: baseName && quoteName ? `${baseName} to ${quoteName} pair` : null, - iconUrl: baseRow.iconUrl ?? null, - assetClass: 'crypto', - base_asset_class: 'crypto', - quote_asset_class: 'crypto', - } -} - -const resolveCryptoWithCurrencyQuote = async ( - baseId: string, - quoteId: string, - signal?: AbortSignal -): Promise<ResolvedListingDetails | null> => { - const [cryptoRecords, currencyRecords] = await Promise.all([ - fetchMarketBatch<any>('crypto', 'crypto_id', [baseId], signal), - fetchMarketBatch<any>('currency', 'currency_id', [quoteId], signal), - ]) - const baseRow = toCodeRow(cryptoRecords[baseId]) - const quoteRow = toCodeRow(currencyRecords[quoteId]) - if (!baseRow?.code || !quoteRow?.code) return null - const baseName = baseRow.name?.trim() || baseRow.code - const quoteName = quoteRow.name?.trim() || quoteRow.code - return { - base: baseRow.code, - quote: quoteRow.code, - name: `${baseName} to ${quoteName} pair`, - iconUrl: baseRow.iconUrl ?? null, - assetClass: 'crypto', - base_asset_class: 'crypto', - quote_asset_class: 'currency', - } -} +export type { ResolvedListingDetails } export async function requestListingResolution( listing: ListingIdentity, signal?: AbortSignal ): Promise<ListingResolved | null> { - if (!listing) return null - - const listingType = listing.listing_type - const listingId = listing.listing_id?.trim() - const baseId = listing.base_id?.trim() - const quoteId = listing.quote_id?.trim() - - if (listingType === 'default') { - if (!listingId) return null - const details = await resolveListingById(listingId, signal) - return details ? buildResolvedListing(listing, details) : null - } - - if (!baseId || !quoteId) return null - - if (listingType === 'currency') { - const details = await resolveCurrencyPair(baseId, quoteId, signal) - return details ? buildResolvedListing(listing, details) : null - } - - if (listingType === 'crypto') { - const isCryptoQuote = quoteId.toUpperCase().includes('CRYP') - const details = isCryptoQuote - ? await resolveCryptoPair(baseId, quoteId, signal) - : await resolveCryptoWithCurrencyQuote(baseId, quoteId, signal) - return details ? buildResolvedListing(listing, details) : null - } - - return null -} - -function buildResolvedListing( - listing: ListingIdentity, - details: ResolvedListingDetails -): ListingResolved | null { - const base = details.base?.trim() - if (!base) return null - - const normalizedIdentity: ListingIdentity = - listing.listing_type === 'default' - ? { - listing_id: listing.listing_id?.trim() ?? '', - base_id: '', - quote_id: '', - listing_type: listing.listing_type, - } - : { - listing_id: '', - base_id: listing.base_id?.trim() ?? '', - quote_id: listing.quote_id?.trim() ?? '', - listing_type: listing.listing_type, - } - - return { - ...normalizedIdentity, - base, - quote: details.quote ?? null, - name: details.name ?? null, - iconUrl: details.iconUrl ?? null, - assetClass: details.assetClass ?? null, - base_asset_class: details.base_asset_class ?? null, - quote_asset_class: details.quote_asset_class ?? null, - marketCode: details.marketCode ?? null, - countryCode: details.countryCode ?? null, - cityName: details.cityName ?? null, - timeZoneName: details.timeZoneName ?? null, - } + return resolveListingIdentity(listing, signal) } diff --git a/apps/tradinggoose/components/ui/chart.tsx b/apps/tradinggoose/components/ui/chart.tsx new file mode 100644 index 000000000..0dc87da7f --- /dev/null +++ b/apps/tradinggoose/components/ui/chart.tsx @@ -0,0 +1,331 @@ +'use client' + +import * as React from 'react' +import type { LegendPayload, TooltipContentProps, TooltipValueType } from 'recharts' +import * as RechartsPrimitive from 'recharts' +import { cn } from '@/lib/utils' + +const THEMES = { light: '', dark: '.dark' } as const + +export type ChartConfig = { + [key: string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { + color?: string + theme?: never + } + | { + color?: never + theme: Record<keyof typeof THEMES, string> + } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext<ChartContextProps | null>(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error('useChart must be used within a <ChartContainer />') + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig + children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'] +}) { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}` + + return ( + <ChartContext.Provider value={{ config }}> + <div + data-chart={chartId} + className={cn( + 'flex aspect-video justify-center text-xs', + '[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground', + '[&_.recharts-cartesian-grid_line[stroke="#ccc"]]:stroke-border/50', + '[&_.recharts-curve.recharts-tooltip-cursor]:stroke-border', + '[&_.recharts-dot[stroke="#fff"]]:stroke-transparent', + '[&_.recharts-layer]:outline-none', + '[&_.recharts-polar-grid_[stroke="#ccc"]]:stroke-border', + '[&_.recharts-radial-bar-background-sector]:fill-muted', + '[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted', + '[&_.recharts-reference-line_[stroke="#ccc"]]:stroke-border', + '[&_.recharts-sector[stroke="#fff"]]:stroke-transparent', + '[&_.recharts-sector]:outline-none', + '[&_.recharts-surface]:outline-none', + className + )} + {...props} + > + <ChartStyle id={chartId} config={config} /> + <RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer> + </div> + </ChartContext.Provider> + ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, itemConfig]) => itemConfig.theme || itemConfig.color + ) + + if (!colorConfig.length) { + return null + } + + return ( + <style + dangerouslySetInnerHTML={{ + __html: Object.entries(THEMES) + .map( + ([theme, prefix]) => `${prefix} [data-chart=${id}] { +${colorConfig + .map(([key, itemConfig]) => { + const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color + return color ? ` --color-${key}: ${color};` : null + }) + .filter(Boolean) + .join('\n')} +}` + ) + .join('\n'), + }} + /> + ) +} + +const ChartTooltip = RechartsPrimitive.Tooltip + +function ChartTooltipContent({ + active, + payload, + className, + indicator = 'dot', + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelClassName, + formatter, + color, + nameKey, + labelKey, +}: Partial<TooltipContentProps<TooltipValueType, string | number>> & + React.ComponentProps<'div'> & { + hideLabel?: boolean + hideIndicator?: boolean + indicator?: 'line' | 'dot' | 'dashed' + nameKey?: string + labelKey?: string + }) { + const { config } = useChart() + + const tooltipLabel = React.useMemo(() => { + if (hideLabel || !payload?.length) { + return null + } + + const [item] = payload + const key = `${labelKey || item?.dataKey || item?.name || 'value'}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const value = + !labelKey && typeof label === 'string' + ? (config[label as keyof typeof config]?.label ?? label) + : itemConfig?.label + + if (labelFormatter) { + return ( + <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div> + ) + } + + if (!value) { + return null + } + + return <div className={cn('font-medium', labelClassName)}>{value}</div> + }, [config, hideLabel, label, labelClassName, labelFormatter, labelKey, payload]) + + if (!active || !payload?.length) { + return null + } + + const nestLabel = payload.length === 1 && indicator !== 'dot' + + return ( + <div + className={cn( + 'grid min-w-[8rem] items-start gap-1.5 rounded-md border border-border/60 bg-background px-2.5 py-1.5 text-xs shadow-md', + className + )} + > + {!nestLabel ? tooltipLabel : null} + <div className='grid gap-1.5'> + {payload.map((item, index) => { + const key = `${nameKey || item.name || item.dataKey || 'value'}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const indicatorColor = color || item.payload?.fill || item.color + + return ( + <div + key={`${item.dataKey ?? index}`} + className={cn( + 'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground', + indicator === 'dot' && 'items-center' + )} + > + {formatter && item?.value !== undefined && item.name !== undefined ? ( + formatter(item.value, item.name, item, index, payload) + ) : ( + <> + {itemConfig?.icon ? ( + <itemConfig.icon /> + ) : ( + !hideIndicator && ( + <div + className={cn( + 'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]', + { + 'h-2.5 w-2.5': indicator === 'dot', + 'w-1': indicator === 'line', + 'w-0 border-[1.5px] border-dashed bg-transparent': + indicator === 'dashed', + 'my-0.5': nestLabel && indicator === 'dashed', + } + )} + style={ + { + '--color-bg': indicatorColor, + '--color-border': indicatorColor, + } as React.CSSProperties + } + /> + ) + )} + <div + className={cn( + 'flex flex-1 justify-between gap-4 leading-none', + nestLabel ? 'items-end' : 'items-center' + )} + > + <div className='grid gap-1.5'> + {nestLabel ? tooltipLabel : null} + <span className='text-muted-foreground'> + {itemConfig?.label ?? item.name} + </span> + </div> + {item.value !== undefined ? ( + <span className='font-medium font-mono text-foreground tabular-nums'> + {item.value.toLocaleString()} + </span> + ) : null} + </div> + </> + )} + </div> + ) + })} + </div> + </div> + ) +} + +const ChartLegend = RechartsPrimitive.Legend + +function ChartLegendContent({ + className, + hideIcon = false, + payload, + verticalAlign = 'bottom', + nameKey, +}: React.ComponentProps<'div'> & { + payload?: ReadonlyArray<LegendPayload> + verticalAlign?: 'top' | 'bottom' | 'middle' + hideIcon?: boolean + nameKey?: string +}) { + const { config } = useChart() + + if (!payload?.length) { + return null + } + + return ( + <div + className={cn( + 'flex items-center justify-center gap-4', + verticalAlign === 'top' ? 'pb-3' : 'pt-3', + className + )} + > + {payload.map((item) => { + const key = `${nameKey || item.dataKey || 'value'}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + + return ( + <div + key={`${item.value ?? item.dataKey ?? 'value'}`} + className='flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground' + > + {itemConfig?.icon && !hideIcon ? ( + <itemConfig.icon /> + ) : ( + <div + className='h-2 w-2 shrink-0 rounded-[2px]' + style={{ + backgroundColor: item.color, + }} + /> + )} + {itemConfig?.label} + </div> + ) + })} + </div> + ) +} + +function getPayloadConfigFromPayload( + config: ChartConfig, + payload: unknown, + key: string +): ChartConfig[string] | undefined { + if (typeof payload !== 'object' || payload === null) { + return undefined + } + + const payloadRecord = payload as Record<string, unknown> + const nestedPayload = + typeof payloadRecord.payload === 'object' && payloadRecord.payload !== null + ? (payloadRecord.payload as Record<string, unknown>) + : undefined + + let configLabelKey = key + + if (typeof payloadRecord[key] === 'string') { + configLabelKey = payloadRecord[key] + } else if (typeof nestedPayload?.[key] === 'string') { + configLabelKey = nestedPayload[key] + } + + return configLabelKey in config ? config[configLabelKey] : config[key] +} + +export { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } diff --git a/apps/tradinggoose/components/ui/index.ts b/apps/tradinggoose/components/ui/index.ts index b4a3833f3..5a1dc191a 100644 --- a/apps/tradinggoose/components/ui/index.ts +++ b/apps/tradinggoose/components/ui/index.ts @@ -23,6 +23,14 @@ export { } from './breadcrumb' export { Button, buttonVariants } from './button' export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './card' +export { + type ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from './chart' export { Checkbox } from './checkbox' export { CodeBlock } from './code-block' export { Collapsible, CollapsibleContent, CollapsibleTrigger } from './collapsible' diff --git a/apps/tradinggoose/components/ui/word-rotate.test.tsx b/apps/tradinggoose/components/ui/word-rotate.test.tsx new file mode 100644 index 000000000..0b4b3c496 --- /dev/null +++ b/apps/tradinggoose/components/ui/word-rotate.test.tsx @@ -0,0 +1,75 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' +import { WordRotate } from './word-rotate' + +vi.mock('framer-motion', () => ({ + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>, + motion: { + span: ({ children, ...props }: React.HTMLAttributes<HTMLSpanElement>) => ( + <span {...props}>{children}</span> + ), + }, +})) + +describe('WordRotate', () => { + let container: HTMLDivElement + let root: Root + let intervalCallbacks: Array<() => void> = [] + + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + intervalCallbacks = [] + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + vi.spyOn(globalThis, 'setInterval').mockImplementation(((handler: TimerHandler) => { + intervalCallbacks.push(handler as () => void) + return 1 as unknown as number + }) as typeof setInterval) + vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => undefined) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.restoreAllMocks() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + it('resets the visible word when the locale copy changes', async () => { + const esWords = getPublicCopy('es').landing.hero.leadWords + const zhWords = getPublicCopy('zh-CN').landing.hero.leadWords + + await act(async () => { + root.render(<WordRotate words={esWords} duration={1000} />) + }) + + expect(container.textContent).toBe(esWords[0]) + + await act(async () => { + intervalCallbacks[0]?.() + }) + + expect(container.textContent).toBe(esWords[1]) + + await act(async () => { + root.render(<WordRotate words={zhWords} duration={1000} />) + }) + + expect(container.textContent).toBe(zhWords[0]) + }) +}) diff --git a/apps/tradinggoose/components/ui/word-rotate.tsx b/apps/tradinggoose/components/ui/word-rotate.tsx index c7e6677b9..81b971018 100644 --- a/apps/tradinggoose/components/ui/word-rotate.tsx +++ b/apps/tradinggoose/components/ui/word-rotate.tsx @@ -34,6 +34,10 @@ export function WordRotate({ words, duration = 3000, className, activeIndex }: W setInternalIndex((prev) => (prev + 1) % words.length) }, [words.length]) + useEffect(() => { + setInternalIndex(0) + }, [words]) + useEffect(() => { if (activeIndex !== undefined) return const id = setInterval(next, duration) diff --git a/apps/tradinggoose/global-navbar/components/sidebar-nav.test.tsx b/apps/tradinggoose/global-navbar/components/sidebar-nav.test.tsx new file mode 100644 index 000000000..4a70ba1b8 --- /dev/null +++ b/apps/tradinggoose/global-navbar/components/sidebar-nav.test.tsx @@ -0,0 +1,43 @@ +import { createElement, type ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { SidebarNav } from './sidebar-nav' + +const { useLocaleMock } = vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'zh-CN'), +})) + +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + +vi.mock('@/components/ui/sidebar', () => ({ + SidebarGroup: ({ children }: { children: ReactNode }) => createElement('section', null, children), + SidebarGroupLabel: ({ children }: { children: ReactNode }) => createElement('h2', null, children), + SidebarMenu: ({ children }: { children: ReactNode }) => createElement('ul', null, children), + SidebarMenuButton: ({ children }: { children: ReactNode }) => createElement('li', null, children), + SidebarMenuItem: ({ children }: { children: ReactNode }) => createElement('div', null, children), +})) + +describe('SidebarNav', () => { + it('prefixes links with the active locale', () => { + useLocaleMock.mockReturnValue('zh-CN') + + const markup = renderToStaticMarkup( + createElement(SidebarNav, { + navItems: [ + { + title: 'Dashboard', + url: '/workspace/ws-1/dashboard', + section: 'workspace', + icon: (() => createElement('svg')) as any, + isActive: true, + }, + ], + }) + ) + + expect(markup).toContain('href="/zh/workspace/ws-1/dashboard"') + expect(markup).toContain('工作区') + }) +}) diff --git a/apps/tradinggoose/global-navbar/components/sidebar-nav.tsx b/apps/tradinggoose/global-navbar/components/sidebar-nav.tsx index 0f08743d8..27153a19a 100644 --- a/apps/tradinggoose/global-navbar/components/sidebar-nav.tsx +++ b/apps/tradinggoose/global-navbar/components/sidebar-nav.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import Link from 'next/link' +import { useLocale } from 'next-intl' import { SidebarGroup, SidebarGroupLabel, @@ -13,6 +14,8 @@ import { import { Skeleton } from '@/components/ui/skeleton' import { openBillingPortal } from '@/lib/billing/billing-portal' import { createLogger } from '@/lib/logs/console/logger' +import { getPublicCopy } from '@/i18n/public-copy' +import { localizeHref, 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' @@ -24,20 +27,22 @@ interface SidebarNavProps { } export function SidebarNav({ navItems }: SidebarNavProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.nav const workspaceItems = navItems.filter((item) => (item.section ?? 'workspace') === 'workspace') const adminItems = navItems.filter((item) => item.section === 'admin') const moreItems = navItems.filter((item) => item.section === 'more') return ( <> - {renderNavGroup('Workspace', workspaceItems)} - {renderNavGroup('System', adminItems)} - {renderNavGroup('More', moreItems)} + {renderNavGroup(locale, copy.groups.workspace, workspaceItems)} + {renderNavGroup(locale, copy.groups.system, adminItems)} + {renderNavGroup(locale, copy.groups.more, moreItems)} </> ) } -function renderNavGroup(label: string, items: NavSection[]) { +function renderNavGroup(locale: LocaleCode, label: string, items: NavSection[]) { if (!items.length) { return null } @@ -49,9 +54,19 @@ function renderNavGroup(label: string, items: NavSection[]) { {items.map((item) => ( <SidebarMenuItem key={item.title}> <SidebarMenuButton asChild isActive={item.isActive} tooltip={item.title}> - <Link href={item.url}> + <Link href={localizeHref(locale, item.url)}> <item.icon /> - <span>{item.title}</span> + { + item.title === 'Files' ? ( + <span className='ml-1'> + {item.title} + </span> + ) : ( + <span> + {item.title} + </span> + ) + } </Link> </SidebarMenuButton> </SidebarMenuItem> diff --git a/apps/tradinggoose/global-navbar/components/user-menu.test.tsx b/apps/tradinggoose/global-navbar/components/user-menu.test.tsx new file mode 100644 index 000000000..76bf9e1cb --- /dev/null +++ b/apps/tradinggoose/global-navbar/components/user-menu.test.tsx @@ -0,0 +1,571 @@ +/** + * @vitest-environment jsdom + */ + +import * as React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as localeSwitcher from '@/app/(landing)/components/nav/locale-switcher' +import { getPublicCopy } from '@/i18n/public-copy' +import { UserMenu } from './user-menu' + +const { + useLocaleMock, + usePathnameMock, + useSearchParamsMock, + useRouterMock, + useOrganizationsMock, + useOrganizationBillingMock, + useSubscriptionDataMock, + useGeneralStoreMock, + generalStoreSetThemeMock, + clearUserDataMock, + signOutMock, +} = vi.hoisted(() => { + const pushMock = vi.fn() + const generalStoreState = { + theme: 'dark', + setTheme: vi.fn(), + isLoading: false, + isThemeLoading: false, + } + + return { + useLocaleMock: vi.fn(() => 'zh-CN'), + usePathnameMock: vi.fn(() => '/workspace/ws-1/dashboard'), + useSearchParamsMock: vi.fn(() => new URLSearchParams('from=nav&source=user-menu')), + useRouterMock: vi.fn(() => ({ push: pushMock })), + useOrganizationsMock: vi.fn(() => ({ + data: { + activeOrganization: null, + billingData: { data: { billingEnabled: false } }, + }, + })), + useOrganizationBillingMock: vi.fn(() => ({ data: undefined, isLoading: false })), + useSubscriptionDataMock: vi.fn(() => ({ data: undefined, isLoading: false })), + useGeneralStoreMock: vi.fn((selector: (state: typeof generalStoreState) => unknown) => + selector(generalStoreState) + ), + generalStoreSetThemeMock: generalStoreState.setTheme, + clearUserDataMock: vi.fn(), + signOutMock: vi.fn(), + } +}) + +vi.mock('next/navigation', () => ({ + useRouter: useRouterMock, + useSearchParams: useSearchParamsMock, +})) + +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + +vi.mock('@/i18n/navigation', () => ({ + usePathname: usePathnameMock, +})) + +vi.mock('@/lib/auth-client', () => ({ + signOut: signOutMock, +})) + +vi.mock('@/stores', () => ({ + clearUserData: clearUserDataMock, +})) + +vi.mock('@/lib/environment', () => ({ + isHosted: false, +})) + +vi.mock('@/hooks/queries/organization', () => ({ + useOrganizations: useOrganizationsMock, + useOrganizationBilling: useOrganizationBillingMock, +})) + +vi.mock('@/hooks/queries/subscription', () => ({ + useSubscriptionData: useSubscriptionDataMock, +})) + +vi.mock('@/stores/settings/general/store', () => ({ + useGeneralStore: useGeneralStoreMock, +})) + +vi.mock('@/global-navbar/settings-modal/components/help/help-modal', () => ({ + HelpModal: () => null, +})) + +vi.mock('@/components/ui/avatar', async () => { + const React = await import('react') + + return { + Avatar: ({ children }: { children?: React.ReactNode }) => + React.createElement('div', { 'data-slot': 'avatar' }, children), + AvatarFallback: ({ children }: { children?: React.ReactNode }) => + React.createElement('div', { 'data-slot': 'avatar-fallback' }, children), + AvatarImage: ({ alt, src }: { alt?: string; src?: string | null }) => + React.createElement('img', { alt: alt ?? '', src: src ?? '' }), + } +}) + +vi.mock('@/components/ui/sidebar', async () => { + const React = await import('react') + + return { + SidebarMenu: ({ children }: { children?: React.ReactNode }) => + React.createElement('div', { 'data-slot': 'sidebar-menu' }, children), + SidebarMenuItem: ({ children }: { children?: React.ReactNode }) => + React.createElement('div', { 'data-slot': 'sidebar-menu-item' }, children), + SidebarMenuButton: React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes<HTMLButtonElement> + >(({ children, type = 'button', ...props }, ref) => + React.createElement('button', { ref, type, ...props }, children) + ), + } +}) + +vi.mock('./resizable-dropdown', async () => { + const React = await import('react') + + const DropdownMenuContext = React.createContext<{ + open: boolean + setOpen: (open: boolean) => void + }>({ + open: false, + setOpen: () => {}, + }) + + const DropdownMenuSubContext = React.createContext<{ + open: boolean + setOpen: React.Dispatch<React.SetStateAction<boolean>> + }>({ + open: false, + setOpen: () => {}, + }) + + const DropdownMenuRadioContext = React.createContext<{ + value?: string + onValueChange?: (value: string) => void + }>({}) + + const DropdownMenu = ({ + children, + open, + onOpenChange, + }: { + children?: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + }) => { + const [internalOpen, setInternalOpen] = React.useState(false) + const isControlled = typeof open === 'boolean' + const currentOpen = isControlled ? open : internalOpen + const setOpen = onOpenChange ?? setInternalOpen + + return React.createElement( + DropdownMenuContext.Provider, + { value: { open: currentOpen, setOpen } }, + children + ) + } + + const DropdownMenuTrigger = ({ + asChild, + children, + }: { + asChild?: boolean + children?: React.ReactNode + }) => { + const context = React.useContext(DropdownMenuContext) + + const handleClick = (event: React.MouseEvent<HTMLElement>) => { + const child = children as React.ReactElement<{ + onClick?: (event: React.MouseEvent<HTMLElement>) => void + }> | null + + if (React.isValidElement(child) && typeof child.props.onClick === 'function') { + child.props.onClick(event) + } + + context.setOpen(!context.open) + } + + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children as React.ReactElement<{ onClick?: typeof handleClick }>, { + onClick: handleClick, + }) + } + + return React.createElement('button', { type: 'button', onClick: handleClick }, children) + } + + const DropdownMenuContent = ({ + children, + className, + sideOffset: _sideOffset, + align: _align, + ...props + }: { + children?: React.ReactNode + className?: string + sideOffset?: number + align?: string + [key: string]: unknown + }) => { + const context = React.useContext(DropdownMenuContext) + + if (!context.open) { + return null + } + + return React.createElement('div', { role: 'menu', className, ...props }, children) + } + + const DropdownMenuGroup = ({ children }: { children?: React.ReactNode }) => + React.createElement('div', { 'data-slot': 'dropdown-menu-group' }, children) + + const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => { + const [open, setOpen] = React.useState(false) + + return React.createElement( + DropdownMenuSubContext.Provider, + { value: { open, setOpen } }, + children + ) + } + + const DropdownMenuSubTrigger = ({ + children, + className, + disabled, + onClick, + ...props + }: { + children?: React.ReactNode + className?: string + disabled?: boolean + onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void + [key: string]: unknown + }) => { + const context = React.useContext(DropdownMenuSubContext) + + return React.createElement( + 'button', + { + type: 'button', + role: 'menuitem', + 'data-slot': 'dropdown-menu-sub-trigger', + 'aria-expanded': context.open, + disabled, + className, + onClick: (event: React.MouseEvent<HTMLButtonElement>) => { + if (disabled) return + onClick?.(event) + context.setOpen((currentOpen) => !currentOpen) + }, + ...props, + }, + children + ) + } + + const DropdownMenuSubContent = ({ + children, + className, + ...props + }: { + children?: React.ReactNode + className?: string + [key: string]: unknown + }) => { + const context = React.useContext(DropdownMenuSubContext) + + if (!context.open) { + return null + } + + return React.createElement('div', { role: 'menu', 'data-slot': 'dropdown-menu-sub-content', className, ...props }, children) + } + + const DropdownMenuItem = ({ + children, + onSelect, + ...props + }: { + children?: React.ReactNode + onSelect?: (event: { preventDefault: () => void }) => void + disabled?: boolean + }) => + React.createElement( + 'button', + { + type: 'button', + role: 'menuitem', + 'data-slot': 'dropdown-menu-item', + onClick: onSelect, + ...props, + }, + children + ) + + const DropdownMenuLabel = ({ children }: { children?: React.ReactNode }) => + React.createElement('div', { 'data-slot': 'dropdown-menu-label' }, children) + + const DropdownMenuSeparator = () => React.createElement('hr', { 'aria-hidden': 'true' }) + + const DropdownMenuRadioGroup = ({ + children, + value, + onValueChange, + }: { + children?: React.ReactNode + value?: string + onValueChange?: (value: string) => void + }) => + React.createElement( + DropdownMenuRadioContext.Provider, + { value: { value, onValueChange } }, + React.createElement( + 'div', + { role: 'radiogroup', 'data-slot': 'dropdown-menu-radio-group' }, + children + ) + ) + + const DropdownMenuRadioItem = ({ + children, + className, + disabled, + onSelect, + value, + ...props + }: { + children?: React.ReactNode + className?: string + disabled?: boolean + onSelect?: (event: { preventDefault: () => void }) => void + value: string + [key: string]: unknown + }) => { + const context = React.useContext(DropdownMenuRadioContext) + const isSelected = context.value === value + + return React.createElement( + 'button', + { + type: 'button', + role: 'menuitemradio', + 'data-slot': 'dropdown-menu-radio-item', + 'aria-checked': isSelected, + 'data-state': isSelected ? 'checked' : 'unchecked', + disabled, + className, + onClick: (event: { preventDefault: () => void }) => { + if (disabled) return + if (onSelect) { + onSelect(event) + return + } + context.onValueChange?.(value) + }, + ...props, + }, + children + ) + } + + return { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + } +}) + +describe('UserMenu selectors', () => { + let container: HTMLDivElement + let root: Root + let navigateSpy: ReturnType<typeof vi.spyOn> + + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + const renderMenu = async () => { + await act(async () => { + root.render( + React.createElement(UserMenu, { + userName: 'Alice', + userEmail: 'alice@example.com', + userAvatar: null, + userAvatarVersion: null, + }) + ) + }) + + const trigger = Array.from(container.querySelectorAll('button')).find( + (button) => + button.textContent?.includes('Alice') && button.textContent?.includes('alice@example.com') + ) + + expect(trigger).toBeTruthy() + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + } + + beforeEach(() => { + vi.clearAllMocks() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + useLocaleMock.mockReturnValue('zh-CN') + usePathnameMock.mockReturnValue('/workspace/ws-1/dashboard') + useSearchParamsMock.mockReturnValue(new URLSearchParams('from=nav&source=user-menu')) + navigateSpy = vi.spyOn(localeSwitcher, 'navigateToLocaleHref').mockImplementation(() => {}) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.restoreAllMocks() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + it('renders localized menu items in the expected order', async () => { + await renderMenu() + + const copy = getPublicCopy('zh-CN') + const themeLabelPrefix = copy.workspace.userMenu.themeLabel.replace('{{theme}}', '') + const selectorRow = container.querySelector('[data-slot="dropdown-menu-group"]') + const selectorTriggers = container.querySelectorAll('[data-slot="dropdown-menu-sub-trigger"]') + const accountDetailLabel = copy.workspace.userMenu.accountDetail + const helpSupportLabel = copy.workspace.userMenu.helpSupport + const themeTrigger = Array.from(selectorTriggers).find((item) => + item.getAttribute('aria-label')?.includes(themeLabelPrefix) + ) + const localeTrigger = Array.from(selectorTriggers).find((item) => + item.textContent?.includes(getPublicCopy('zh-CN').localeNames['zh-CN']) + ) + const accountDetail = Array.from( + container.querySelectorAll('[data-slot="dropdown-menu-item"]') + ).find((item) => item.textContent?.includes(accountDetailLabel)) + const helpSupport = Array.from(container.querySelectorAll('[data-slot="dropdown-menu-item"]')).find( + (item) => item.textContent?.includes(helpSupportLabel) + ) + expect(selectorRow).toBeInTheDocument() + expect(selectorTriggers.length).toBe(2) + expect(themeTrigger).toBeInTheDocument() + expect(localeTrigger).toBeInTheDocument() + expect(accountDetail).toBeInTheDocument() + expect(helpSupport).toBeInTheDocument() + expect(accountDetail).toHaveTextContent(accountDetailLabel) + expect(helpSupport).toHaveTextContent(helpSupportLabel) + expect(localeTrigger).toHaveTextContent(copy.localeNames['zh-CN']) + + expect( + selectorRow!.compareDocumentPosition(accountDetail!) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + expect( + accountDetail!.compareDocumentPosition(helpSupport!) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + expect( + themeTrigger!.compareDocumentPosition(localeTrigger!) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) + + it('updates the theme when a different theme is selected', async () => { + await renderMenu() + + const copy = getPublicCopy('zh-CN') + const themeLabelPrefix = copy.workspace.userMenu.themeLabel.replace('{{theme}}', '') + const themeTrigger = Array.from( + container.querySelectorAll('[data-slot="dropdown-menu-sub-trigger"]') + ).find((item) => item.getAttribute('aria-label')?.includes(themeLabelPrefix)) + + expect(themeTrigger).toBeInTheDocument() + + await act(async () => { + themeTrigger?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + const [lightTheme] = Array.from(container.querySelectorAll('[role="menuitemradio"]')) + + expect(lightTheme).toBeInTheDocument() + + await act(async () => { + lightTheme?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + expect(generalStoreSetThemeMock).toHaveBeenCalledWith('light') + }) + + it('navigates to the localized href when a different locale is selected', async () => { + await renderMenu() + + const copy = getPublicCopy('zh-CN') + const localeTrigger = Array.from( + container.querySelectorAll('[data-slot="dropdown-menu-sub-trigger"]') + ).find((item) => item.textContent?.includes(copy.localeNames['zh-CN'])) + + expect(localeTrigger).toBeInTheDocument() + + await act(async () => { + localeTrigger?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + const spanishItem = Array.from(container.querySelectorAll('[role="menuitemradio"]')).find( + (item) => item.textContent?.includes(copy.localeNames.es) + ) + + expect(spanishItem).toBeInTheDocument() + + await act(async () => { + spanishItem?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + expect(navigateSpy).toHaveBeenCalledWith( + '/es/workspace/ws-1/dashboard?from=nav&source=user-menu' + ) + }) + + it('does not navigate when the active locale is selected again', async () => { + await renderMenu() + + const copy = getPublicCopy('zh-CN') + const localeTrigger = Array.from( + container.querySelectorAll('[data-slot="dropdown-menu-sub-trigger"]') + ).find((item) => item.textContent?.includes(copy.localeNames['zh-CN'])) + + expect(localeTrigger).toBeInTheDocument() + + await act(async () => { + localeTrigger?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + const activeLocaleItem = Array.from(container.querySelectorAll('[role="menuitemradio"]')).find( + (item) => item.textContent?.includes(copy.localeNames['zh-CN']) + ) + + expect(activeLocaleItem).toBeInTheDocument() + + await act(async () => { + activeLocaleItem?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + expect(navigateSpy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/global-navbar/components/user-menu.tsx b/apps/tradinggoose/global-navbar/components/user-menu.tsx index 91f08128f..2a4c433d4 100644 --- a/apps/tradinggoose/global-navbar/components/user-menu.tsx +++ b/apps/tradinggoose/global-navbar/components/user-menu.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react' import { ChevronsUpDown, CreditCard, + ChevronDown, KeyRound, LifeBuoy, LogIn, @@ -17,7 +18,8 @@ import { User, Users, } from 'lucide-react' -import { useRouter } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' import { signOut } from '@/lib/auth-client' @@ -27,10 +29,17 @@ import { createLogger } from '@/lib/logs/console/logger' import { getOrganizationAccessState } from '@/lib/organization/access' import { getUserRole } from '@/lib/organization/helpers' import { getSubscriptionStatus } from '@/lib/subscription/helpers' +import { + buildLocaleSwitchHref, + navigateToLocaleHref, +} from '@/app/(landing)/components/nav/locale-switcher' import { HelpModal } from '@/global-navbar/settings-modal/components/help/help-modal' import type { SettingsSection } from '@/global-navbar/settings-modal/types' import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' +import { usePathname } from '@/i18n/navigation' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { isLocaleCode, type LocaleCode, locales, localizeHref } from '@/i18n/utils' import { clearUserData } from '@/stores' import { useGeneralStore } from '@/stores/settings/general/store' import { getInitials } from '../utils' @@ -39,27 +48,32 @@ import { DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from './resizable-dropdown' type ThemeOption = { value: 'light' | 'system' | 'dark' - label: string Icon: LucideIcon } const THEME_OPTIONS: ThemeOption[] = [ - { value: 'light', label: 'Light', Icon: Sun }, - { value: 'system', label: 'System', Icon: Monitor }, - { value: 'dark', label: 'Dark', Icon: Moon }, + { value: 'light', Icon: Sun }, + { value: 'system', Icon: Monitor }, + { value: 'dark', Icon: Moon }, ] -const THEME_ITEM_BASE_CLASSES = - 'relative flex h-9 flex-1 items-center justify-center gap-0 rounded-md border px-0 py-0 text-sm transition-colors focus:bg-accent focus:text-accent-foreground' -const THEME_ITEM_ACTIVE_CLASSES = 'border-border bg-accent text-accent-foreground shadow-sm' -const THEME_ITEM_INACTIVE_CLASSES = - 'border-transparent text-muted-foreground hover:bg-card hover:text-foreground' +const SELECTOR_TRIGGER_BASE_CLASSES = + 'flex h-9 cursor-pointer items-center rounded-md border border-border px-2 py-0 font-medium text-foreground text-sm transition-colors hover:bg-card focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground disabled:pointer-events-none disabled:opacity-50' +const THEME_SELECTOR_TRIGGER_CLASSES = `${SELECTOR_TRIGGER_BASE_CLASSES} w-9 justify-center px-0 [&>svg:last-child]:hidden` +const LOCALE_SELECTOR_TRIGGER_CLASSES = `${SELECTOR_TRIGGER_BASE_CLASSES} min-w-0 flex-1 justify-between gap-2 [&>svg:last-child]:hidden` +const SELECTOR_SUBMENU_CONTENT_CLASSES = 'w-48 rounded-lg' const DEFAULT_AVATAR_SRC = '/profile/avatar.png' @@ -86,6 +100,10 @@ export function UserMenu({ systemNavigation, }: UserMenuProps) { const router = useRouter() + const locale = useLocale() as LocaleCode + const pathname = usePathname() + const searchParams = useSearchParams() + const copy = getPublicCopy(locale) const [isSigningOut, setIsSigningOut] = useState(false) const [isOpeningBillingPortal, setIsOpeningBillingPortal] = useState(false) const [avatarOverride, setAvatarOverride] = useState<{ @@ -98,7 +116,11 @@ export function UserMenu({ const isGeneralLoading = useGeneralStore((state) => state.isLoading) const isThemeLoading = useGeneralStore((state) => state.isThemeLoading) const { data: organizationsData } = useOrganizations() - const currentThemeLabel = THEME_OPTIONS.find((option) => option.value === theme)?.label ?? 'Theme' + const userMenuCopy = copy.workspace.userMenu + const themeOptionLabels = userMenuCopy.themeOptions + const currentThemeOption = + THEME_OPTIONS.find((option) => option.value === theme) ?? THEME_OPTIONS[0] + const currentThemeLabel = themeOptionLabels[currentThemeOption.value] const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const activeOrganization = organizationsData?.activeOrganization const activeOrganizationId = activeOrganization?.id @@ -204,7 +226,7 @@ export function UserMenu({ } catch (error) { logger.error('Error signing out:', { error }) } finally { - router.push('/login?fromLogout=true') + router.push(localizeHref(locale, '/login?fromLogout=true')) setIsSigningOut(false) } } @@ -218,6 +240,14 @@ export function UserMenu({ } } + const handleLocaleChange = (nextLocale: string) => { + if (!isLocaleCode(nextLocale) || nextLocale === locale) { + return + } + + navigateToLocaleHref(buildLocaleSwitchHref(nextLocale, pathname, searchParams)) + } + const handleOpenBillingPortal = async () => { if (!billingEnabled) return if (isOpeningBillingPortal || isSubscriptionLoading) return @@ -227,7 +257,7 @@ export function UserMenu({ logger.error('Cannot open billing portal without an active organization', { tier: subscription.tier.displayName, }) - alert('Select an organization to manage billing.') + alert(userMenuCopy.billingPortalSelectOrganization) return } @@ -239,7 +269,7 @@ export function UserMenu({ }) } catch (error) { logger.error('Failed to open billing portal from user menu', { error }) - alert(error instanceof Error ? error.message : 'Failed to open billing portal') + alert(error instanceof Error ? error.message : userMenuCopy.billingPortalFailed) } finally { setIsOpeningBillingPortal(false) } @@ -260,7 +290,7 @@ export function UserMenu({ {avatarSrc ? ( <AvatarImage key={avatarSrc} src={avatarSrc} alt={userName} /> ) : ( - <AvatarImage src={DEFAULT_AVATAR_SRC} alt='Default avatar' /> + <AvatarImage src={DEFAULT_AVATAR_SRC} alt={userMenuCopy.defaultAvatarAlt} /> )} <AvatarFallback className='rounded-lg'>{getInitials(userName)}</AvatarFallback> </Avatar> @@ -277,34 +307,67 @@ export function UserMenu({ align='start' > <DropdownMenuGroup> - <div className='flex items-center gap-1.5 px-2 pt-0.5 pb-1.5'> - <DropdownMenuItem className='flex items-center gap-2 font-medium text-muted-foreground text-sm'> - {currentThemeLabel} - </DropdownMenuItem> - {THEME_OPTIONS.map(({ value, label, Icon }) => { - const isActive = theme === value - const themeClasses = `${THEME_ITEM_BASE_CLASSES} ${ - isActive ? THEME_ITEM_ACTIVE_CLASSES : THEME_ITEM_INACTIVE_CLASSES - }` - return ( - <DropdownMenuItem - key={value} - aria-label={`${label} theme`} - className={themeClasses} - disabled={isThemeLoading || isGeneralLoading} - onSelect={(event) => { - if (isActive) { - event.preventDefault() - return - } - void handleThemeChange(value) - }} - title={label} - > - <Icon className='size-4' /> - </DropdownMenuItem> - ) - })} + <div className='grid grid-cols-[2.25rem_minmax(0,1fr)] items-center gap-1.5 px-2 pt-0.5 pb-1.5'> + <DropdownMenuSub> + <DropdownMenuSubTrigger + aria-label={formatTemplate(userMenuCopy.themeLabel, { + theme: currentThemeLabel, + })} + className={THEME_SELECTOR_TRIGGER_CLASSES} + disabled={isThemeLoading || isGeneralLoading} + title={currentThemeLabel} + > + <currentThemeOption.Icon className='size-4' /> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent className={SELECTOR_SUBMENU_CONTENT_CLASSES}> + <DropdownMenuRadioGroup value={theme}> + {THEME_OPTIONS.map(({ value, Icon }) => { + const label = themeOptionLabels[value] + const isActive = theme === value + + return ( + <DropdownMenuRadioItem + key={value} + className='flex items-center gap-2' + disabled={isThemeLoading || isGeneralLoading} + onSelect={(event) => { + if (isActive) { + event.preventDefault() + return + } + void handleThemeChange(value) + }} + value={value} + > + <Icon className='size-4' /> + {label} + </DropdownMenuRadioItem> + ) + })} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + <DropdownMenuSub> + <DropdownMenuSubTrigger + className={LOCALE_SELECTOR_TRIGGER_CLASSES} + title={copy.localeNames[locale]} + > + <span className='min-w-0 truncate'>{copy.localeNames[locale]}</span> + <ChevronDown className='size-4 shrink-0' aria-hidden='true' /> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent className={SELECTOR_SUBMENU_CONTENT_CLASSES}> + <DropdownMenuLabel className='px-2 py-1.5 font-medium text-muted-foreground text-sm'> + {userMenuCopy.languageLabel} + </DropdownMenuLabel> + <DropdownMenuRadioGroup value={locale} onValueChange={handleLocaleChange}> + {locales.map((code) => ( + <DropdownMenuRadioItem key={code} value={code}> + {copy.localeNames[code]} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> </div> </DropdownMenuGroup> <DropdownMenuSeparator /> @@ -322,7 +385,7 @@ export function UserMenu({ }} > <User /> - Account Detail + {userMenuCopy.accountDetail} </DropdownMenuItem> {isHosted ? ( <DropdownMenuItem @@ -338,7 +401,7 @@ export function UserMenu({ }} > <KeyRound /> - Service API Keys + {userMenuCopy.serviceApiKeys} </DropdownMenuItem> ) : null} </DropdownMenuGroup> @@ -359,7 +422,7 @@ export function UserMenu({ }} > <Star /> - Subscription + {userMenuCopy.subscription} </DropdownMenuItem> <DropdownMenuItem disabled={isOpeningBillingPortal || isSubscriptionLoading} @@ -369,7 +432,9 @@ export function UserMenu({ }} > <CreditCard /> - {isOpeningBillingPortal ? 'Opening Billing…' : 'Manage Billing'} + {isOpeningBillingPortal + ? userMenuCopy.openingBilling + : userMenuCopy.manageBilling} </DropdownMenuItem> </DropdownMenuGroup> </> @@ -392,7 +457,7 @@ export function UserMenu({ }} > <Users /> - Team Management + {userMenuCopy.teamManagement} </DropdownMenuItem> ) : null} {canManageSSOSettings ? ( @@ -409,7 +474,7 @@ export function UserMenu({ }} > <LogIn /> - Single Sign-On + {userMenuCopy.singleSignOn} </DropdownMenuItem> ) : null} </DropdownMenuGroup> @@ -422,7 +487,7 @@ export function UserMenu({ <DropdownMenuItem onSelect={(event) => { event.preventDefault() - router.push(systemNavigation.href) + router.push(localizeHref(locale, systemNavigation.href)) }} > <ShieldCheck /> @@ -440,7 +505,7 @@ export function UserMenu({ }} > <LifeBuoy /> - Help & Support + {userMenuCopy.helpSupport} </DropdownMenuItem> </DropdownMenuGroup> <DropdownMenuSeparator /> @@ -453,7 +518,7 @@ export function UserMenu({ className='text-destructive focus:text-destructive' > <LogOut className='text-destructive ' /> - {isSigningOut ? 'Logging out…' : 'Log out'} + {isSigningOut ? userMenuCopy.loggingOut : userMenuCopy.logOut} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> diff --git a/apps/tradinggoose/global-navbar/components/workspace-switcher.tsx b/apps/tradinggoose/global-navbar/components/workspace-switcher.tsx index c6b6337fd..bd483cc87 100644 --- a/apps/tradinggoose/global-navbar/components/workspace-switcher.tsx +++ b/apps/tradinggoose/global-navbar/components/workspace-switcher.tsx @@ -1,11 +1,14 @@ 'use client' import { ChevronsUpDown, Loader2, Pencil, Plus, Settings, Trash2 } from 'lucide-react' import Image from 'next/image' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' import { Skeleton } from '@/components/ui/skeleton' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import type { Workspace } from '../types' import { getInitials } from '../utils' @@ -34,7 +37,6 @@ interface WorkspaceSwitcherProps { isCreatingWorkspace: boolean onDeleteWorkspace: (workspace: Workspace) => void brandName: string - fallbackSubtitle?: string fallbackImageUrl: string } @@ -61,9 +63,10 @@ export function WorkspaceSwitcher({ isCreatingWorkspace, onDeleteWorkspace, brandName, - fallbackSubtitle = 'Workspace', fallbackImageUrl, }: WorkspaceSwitcherProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.switcher return ( <SidebarMenu> <SidebarMenuItem> @@ -90,7 +93,10 @@ export function WorkspaceSwitcher({ <div className='grid flex-1 text-left text-sm leading-tight'> <span className='truncate font-semibold'>{activeWorkspace?.name ?? brandName}</span> <span className='truncate text-xs'> - {activeWorkspace?.role ?? fallbackSubtitle} + {activeWorkspace?.role + ? copy.roles[activeWorkspace.role as keyof typeof copy.roles] ?? + activeWorkspace.role + : copy.workspaceLabel} </span> </div> <ChevronsUpDown className='ml-auto' /> @@ -113,9 +119,7 @@ export function WorkspaceSwitcher({ </div> ) : workspaces.length === 0 ? ( <div className='rounded-md border border-dashed p-4 text-center text-muted-foreground text-sm'> - {canManageWorkspaces - ? 'No workspaces yet. Create one to get started.' - : 'No workspaces available.'} + {canManageWorkspaces ? copy.noWorkspacesYet : copy.noWorkspacesAvailable} </div> ) : ( <div className='space-y-1'> @@ -229,7 +233,7 @@ export function WorkspaceSwitcher({ disabled={!activeWorkspace || activeWorkspace.permissions !== 'admin'} > <Settings className='h-3.5 w-3.5' /> - Manage + {copy.manage} </Button> <Button variant='secondary' @@ -240,12 +244,12 @@ export function WorkspaceSwitcher({ {isCreatingWorkspace ? ( <> <Loader2 className='h-3.5 w-3.5 animate-spin' /> - Creating… + {copy.creating} </> ) : ( <> <Plus className='h-3.5 w-3.5' /> - Create + {copy.create} </> )} </Button> diff --git a/apps/tradinggoose/global-navbar/global-navbar.test.tsx b/apps/tradinggoose/global-navbar/global-navbar.test.tsx new file mode 100644 index 000000000..65ea62d34 --- /dev/null +++ b/apps/tradinggoose/global-navbar/global-navbar.test.tsx @@ -0,0 +1,203 @@ +import { createElement, type ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { GlobalNavbar } from './global-navbar' + +const { useLocaleMock, usePathnameMock, useSessionMock, useWorkspaceSwitcherMock } = + vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'zh-CN'), + usePathnameMock: vi.fn(() => '/zh/workspace/ws-1/dashboard'), + useSessionMock: vi.fn(() => ({ + data: { + user: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + image: null, + updatedAt: null, + }, + }, + isPending: false, + })), + useWorkspaceSwitcherMock: vi.fn(() => ({ + activeWorkspace: { + id: 'ws-1', + name: 'Workspace One', + }, + workspaces: [], + isWorkspacesLoading: false, + canManageWorkspaces: false, + workspaceMenuOpen: false, + setWorkspaceMenuOpen: vi.fn(), + hoveredWorkspaceId: null, + setHoveredWorkspaceId: vi.fn(), + editingWorkspaceId: null, + editingWorkspaceName: '', + setEditingWorkspaceName: vi.fn(), + isRenamingWorkspace: false, + renameError: null, + handleStartEditing: vi.fn(), + handleCancelEditing: vi.fn(), + handleSaveWorkspaceName: vi.fn(), + handleSwitchWorkspace: vi.fn(), + handleCreateWorkspace: vi.fn(), + inviteDialogOpen: false, + handleInviteDialogChange: vi.fn(), + inviteWorkspace: null, + handleOpenInviteDialog: vi.fn(), + deleteDialogOpen: false, + handleDeleteDialogChange: vi.fn(), + workspaceToDelete: null, + setWorkspaceToDelete: vi.fn(), + deleteError: null, + isDeletingWorkspace: false, + handleConfirmDelete: vi.fn(), + })), + })) + +vi.mock('next/navigation', () => ({ + usePathname: usePathnameMock, +})) + +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + +vi.mock('@/lib/auth-client', () => ({ + useSession: useSessionMock, +})) + +vi.mock('@/lib/branding/branding', () => ({ + getBrandConfig: () => ({ + name: 'TradingGoose', + faviconUrl: '/favicon.ico', + supportEmail: 'support@tradinggoose.ai', + }), +})) + +vi.mock('@/lib/environment', () => ({ + isHosted: false, +})) + +vi.mock('@/lib/organization/access', () => ({ + getOrganizationAccessState: () => ({ + canOpenTeamSettings: true, + }), +})) + +vi.mock('@/lib/organization/helpers', () => ({ + getUserRole: () => 'owner', +})) + +vi.mock('@/hooks/queries/subscription', () => ({ + useSubscriptionData: () => ({ + data: { + billingEnabled: true, + billingBlocked: false, + tier: { ownerType: 'user', displayName: 'Pro' }, + }, + isLoading: false, + isError: false, + }), +})) + +vi.mock('@/hooks/queries/organization', () => ({ + useOrganizations: () => ({ + data: { + activeOrganization: { + id: 'org-1', + ownerId: 'user-1', + }, + billingData: { data: { billingEnabled: true } }, + }, + }), + useOrganizationBilling: () => ({ data: undefined, isLoading: false }), +})) + +vi.mock('./components/navbar-header', () => ({ + NavbarHeader: ({ pageTitle }: { pageTitle?: string }) => + createElement('div', { 'data-testid': 'navbar-header', 'data-page-title': pageTitle ?? '' }), +})) + +vi.mock('./components/workspace-switcher', () => ({ + WorkspaceSwitcher: () => createElement('div', { 'data-testid': 'workspace-switcher' }), +})) + +vi.mock('./components/sidebar-nav', () => ({ + SidebarNav: ({ navItems }: { navItems: Array<{ title: string; isActive?: boolean; url: string }> }) => + createElement( + 'nav', + { 'data-testid': 'sidebar-nav' }, + navItems.map((item) => + createElement( + 'div', + { + key: item.url, + 'data-testid': `nav-${item.title}`, + 'data-active': item.isActive ? 'true' : 'false', + 'data-url': item.url, + }, + item.title + ) + ) + ), + SidebarUsageIndicator: () => null, +})) + +vi.mock('./components/user-menu', () => ({ + UserMenu: () => createElement('div', { 'data-testid': 'user-menu' }), +})) + +vi.mock('./components/workspace-dialogs', () => ({ + WorkspaceDialogs: () => null, +})) + +vi.mock('./settings-modal/settings-dialog', () => ({ + SettingsDialog: () => null, +})) + +vi.mock('./header-context', () => ({ + GlobalNavbarHeaderProvider: ({ children }: { children: ReactNode }) => createElement('div', null, children), +})) + +vi.mock('@/components/ui/sidebar', () => ({ + Sidebar: ({ children }: { children: ReactNode }) => createElement('div', { 'data-testid': 'sidebar' }, children), + SidebarContent: ({ children }: { children: ReactNode }) => + createElement('div', { 'data-testid': 'sidebar-content' }, children), + SidebarFooter: ({ children }: { children: ReactNode }) => + createElement('div', { 'data-testid': 'sidebar-footer' }, children), + SidebarHeader: ({ children }: { children: ReactNode }) => + createElement('div', { 'data-testid': 'sidebar-header' }, children), + SidebarInset: ({ children }: { children: ReactNode }) => + createElement('div', { 'data-testid': 'sidebar-inset' }, children), + SidebarProvider: ({ children }: { children: ReactNode }) => + createElement('div', { 'data-testid': 'sidebar-provider' }, children), + SidebarRail: () => null, +})) + +vi.mock('./use-workspace-switcher', () => ({ + useWorkspaceSwitcher: useWorkspaceSwitcherMock, +})) + +describe('GlobalNavbar', () => { + it('renders the workspace sidebar on locale-prefixed workspace routes', () => { + useLocaleMock.mockReturnValue('zh-CN') + usePathnameMock.mockReturnValue('/zh/workspace/ws-1/dashboard') + + const markup = renderToStaticMarkup( + createElement( + GlobalNavbar, + { + navigationMode: 'workspace', + isSystemAdmin: false, + children: createElement('div', { 'data-testid': 'content' }), + } + ) + ) + + expect(markup).toContain('data-testid="sidebar-nav"') + expect(markup).toContain('data-active="true"') + expect(markup).toContain('data-url="/workspace/ws-1/dashboard"') + expect(markup).toContain('data-testid="content"') + }) +}) diff --git a/apps/tradinggoose/global-navbar/global-navbar.tsx b/apps/tradinggoose/global-navbar/global-navbar.tsx index 0f598e05e..30ed04850 100644 --- a/apps/tradinggoose/global-navbar/global-navbar.tsx +++ b/apps/tradinggoose/global-navbar/global-navbar.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { usePathname } from 'next/navigation' +import { useLocale } from 'next-intl' import { Sidebar, SidebarContent, @@ -17,6 +18,8 @@ import { getBrandConfig } from '@/lib/branding/branding' import { isHosted } from '@/lib/environment' import { getOrganizationAccessState } from '@/lib/organization/access' import { getUserRole } from '@/lib/organization/helpers' +import { getPublicCopy } from '@/i18n/public-copy' +import { localizeHref, stripLocaleFromPathname, type LocaleCode } from '@/i18n/utils' import { useOrganizations } from '@/hooks/queries/organization' import { NavbarHeader } from './components/navbar-header' import { SidebarNav, SidebarUsageIndicator } from './components/sidebar-nav' @@ -48,26 +51,40 @@ export function GlobalNavbar({ navigationMode?: 'workspace' | 'admin' }) { const pathname = usePathname() ?? '/' + const locale = useLocale() as LocaleCode const brand = React.useMemo(() => getBrandConfig(), []) + const workspaceCopy = React.useMemo(() => getPublicCopy(locale).workspace, [locale]) + const { pathname: normalizedPathname } = React.useMemo( + () => stripLocaleFromPathname(pathname), + [pathname] + ) const { data: sessionData, isPending: isSessionLoading } = useSession() - const workspaceId = React.useMemo(() => getWorkspaceIdFromPath(pathname), [pathname]) + const workspaceId = React.useMemo( + () => getWorkspaceIdFromPath(normalizedPathname), + [normalizedPathname] + ) const navItems = React.useMemo( - () => (navigationMode === 'admin' ? createAdminNav() : createWorkspaceNav(workspaceId)), - [navigationMode, workspaceId] + () => + navigationMode === 'admin' + ? createAdminNav(locale) + : createWorkspaceNav(locale, workspaceId), + [locale, navigationMode, workspaceId] ) const navMain = React.useMemo<NavSection[]>( - () => createNavSections(pathname, navItems), - [pathname, navItems] + () => createNavSections(normalizedPathname, navItems), + [navItems, normalizedPathname] ) 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) => pathname.startsWith(route)), - [pathname] + () => AUTH_ROUTE_PREFIXES.some((route) => normalizedPathname.startsWith(route)), + [normalizedPathname] ) const isLandingRoute = React.useMemo( - () => pathname === '/' || LANDING_ROUTE_PREFIXES.some((route) => pathname.startsWith(route)), - [pathname] + () => + 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 @@ -112,10 +129,10 @@ export function GlobalNavbar({ } return { - href: '/admin', - label: 'System Admin', + href: localizeHref(locale, '/admin'), + label: workspaceCopy.nav.systemAdmin, } - }, [isSystemAdmin, navigationMode]) + }, [isSystemAdmin, locale, navigationMode, workspaceCopy.nav.systemAdmin]) const resolveSettingsSection = React.useCallback( (section: SettingsSection): SettingsSection => { diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.tsx index d22b8b88d..88fe070a9 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.tsx @@ -11,6 +11,7 @@ import { useState, } from 'react' import { AlertCircle, Check, Info, Loader2, Pencil, X } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' @@ -24,6 +25,8 @@ import { getBaseUrl } from '@/lib/urls/utils' import { useProfilePictureUpload } from '@/global-navbar/settings-modal/components/hooks/use-profile-picture-upload' import { useGeneralSettings } from '@/hooks/queries/general-settings' import { useGeneralStore } from '@/stores/settings/general/store' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' const logger = createLogger('AccountSettings') const DEFAULT_AVATAR_SRC = '/profile/avatar.png' @@ -35,6 +38,8 @@ const toEpochMillis = (value: string | Date | null | undefined): number | null = } export function AccountSettings() { + const locale = useLocale() as LocaleCode + const accountCopy = getPublicCopy(locale).workspace.settingsModal.account const { data: session } = useSession() const userId = session?.user?.id ?? null @@ -100,12 +105,12 @@ export function AccountSettings() { typeof errorData?.error === 'string' ? errorData.error : imageUrl - ? 'Failed to update profile picture' - : 'Failed to remove profile picture' + ? accountCopy.status.profilePictureUpdateError + : accountCopy.status.profilePictureRemoveError throw new Error(message) } - setMessage('Profile saved.') + setMessage(accountCopy.status.profileSaved) setUserImage(imageUrl) const version = Date.now() setAvatarVersion(version) @@ -121,7 +126,7 @@ export function AccountSettings() { } catch (error) { logger.error('Failed to update profile picture', error) setProfilePictureError( - error instanceof Error ? error.message : 'Unable to update profile picture.' + error instanceof Error ? error.message : accountCopy.status.unableToUpdateProfilePicture ) throw error } @@ -141,7 +146,7 @@ export function AccountSettings() { setProfilePictureError(null) } catch (error) { setProfilePictureError( - error instanceof Error ? error.message : 'Unable to update profile picture.' + error instanceof Error ? error.message : accountCopy.status.unableToUpdateProfilePicture ) } }, @@ -195,7 +200,7 @@ export function AccountSettings() { const handleSave = async () => { if (!name.trim()) { - setMessage('Please provide a name.') + setMessage(accountCopy.status.nameRequired) return } setIsSaving(true) @@ -206,10 +211,10 @@ export function AccountSettings() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email }), }) - setMessage('Profile saved.') + setMessage(accountCopy.status.profileSaved) } catch (error) { logger.error('Failed to save profile', error) - setMessage('Unable to save profile settings.') + setMessage(accountCopy.status.saveError) } finally { setIsSaving(false) } @@ -234,7 +239,7 @@ export function AccountSettings() { const commitEditingName = async () => { const trimmedName = editingNameValue.trim() if (!trimmedName) { - setNameError('Name is required') + setNameError(accountCopy.status.nameRequiredValidation) editNameInputRef.current?.focus() return } @@ -256,7 +261,7 @@ export function AccountSettings() { if (!response.ok) { const errorData = await response.json().catch(() => ({})) const message = - typeof errorData?.error === 'string' ? errorData.error : 'Failed to update name' + typeof errorData?.error === 'string' ? errorData.error : accountCopy.status.failedUpdateName setNameError(message) editNameInputRef.current?.focus() return @@ -264,7 +269,7 @@ export function AccountSettings() { setName(trimmedName) setIsEditingName(false) - setMessage('Profile saved.') + setMessage(accountCopy.status.profileSaved) if (typeof window !== 'undefined') { if (userId) { window.localStorage.setItem(`user-name-${userId}`, trimmedName) @@ -273,7 +278,7 @@ export function AccountSettings() { } } catch (error) { logger.error('Error updating name:', error) - setNameError('Unable to update name. Please try again.') + setNameError(accountCopy.status.unableToUpdateName) editNameInputRef.current?.focus() } finally { setIsUpdatingName(false) @@ -285,7 +290,7 @@ export function AccountSettings() { if (!targetEmail) { setPasswordResetStatus({ type: 'error', - message: 'No email address found for this account.', + message: accountCopy.status.noEmail, }) return } @@ -306,18 +311,18 @@ export function AccountSettings() { if (!response.ok) { const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.message || 'Failed to send password reset email.') + throw new Error(errorData.message || accountCopy.status.passwordResetFailed) } setPasswordResetStatus({ type: 'success', - message: 'Password reset link sent to your inbox.', + message: accountCopy.status.passwordResetSent, }) } catch (error) { logger.error('Error requesting password reset:', error) setPasswordResetStatus({ type: 'error', - message: error instanceof Error ? error.message : 'Unable to send password reset email.', + message: error instanceof Error ? error.message : accountCopy.status.passwordResetFailed, }) } finally { setIsSendingReset(false) @@ -373,7 +378,7 @@ export function AccountSettings() { <div className='grid gap-6 p-6 sm:grid-cols-[280px,1fr] '> <Card className='border-none shadow-none'> <CardHeader className='pb-4'> - <CardTitle className='text-base font-semibold'>Profile Picture</CardTitle> + <CardTitle className='text-base font-semibold'>{accountCopy.profilePicture}</CardTitle> </CardHeader> <CardContent className='space-y-4'> <div @@ -398,7 +403,7 @@ export function AccountSettings() { {avatarSrc ? ( <Image src={avatarSrc} - alt={name || session?.user?.name || 'User'} + alt={name || session?.user?.name || accountCopy.profilePictureAlt} width={96} height={96} className='h-full w-full object-cover' @@ -413,8 +418,8 @@ export function AccountSettings() { )} </div> <div className='space-y-1'> - <p className='font-medium text-sm'>Drop an image or click to upload</p> - <p className='text-muted-foreground text-xs'>PNG or JPG, max 5MB</p> + <p className='font-medium text-sm'>{accountCopy.dropImage}</p> + <p className='text-muted-foreground text-xs'>{accountCopy.imageHint}</p> </div> </div> @@ -428,13 +433,15 @@ export function AccountSettings() { </Card> <Card className='border-none shadow-none'> <CardHeader className='space-y-1 pb-5'> - <CardTitle className='text-lg font-semibold'>Profile Details</CardTitle> - <p className='text-muted-foreground text-sm'>Update your name and manage access.</p> + <CardTitle className='text-lg font-semibold'>{accountCopy.profileDetails}</CardTitle> + <p className='text-muted-foreground text-sm'> + {accountCopy.profileDetailsDescription} + </p> </CardHeader> <CardContent className='space-y-5'> <div className='space-y-3'> <div className='space-y-1'> - <Label htmlFor='accountName'>Full name</Label> + <Label htmlFor='accountName'>{accountCopy.fullName}</Label> {isEditingName ? ( <div className='py-1.5'> <div className='flex items-center gap-2 max-w-md'> @@ -476,7 +483,7 @@ export function AccountSettings() { disabled={isUpdatingName} > <Check className='h-3.5 w-3.5' /> - <span className='sr-only'>Save name</span> + <span className='sr-only'>{accountCopy.saveName}</span> </button> <button type='button' @@ -488,7 +495,7 @@ export function AccountSettings() { disabled={isUpdatingName} > <X className='h-3.5 w-3.5' /> - <span className='sr-only'>Cancel editing name</span> + <span className='sr-only'>{accountCopy.cancelEditingName}</span> </button> </div> {nameError && <p className='text-destructive text-xs'>{nameError}</p>} @@ -503,25 +510,27 @@ export function AccountSettings() { disabled={isUpdatingName} > <Pencil className='h-3.5 w-3.5' /> - <span className='sr-only'>Edit name</span> + <span className='sr-only'>{accountCopy.editName}</span> </button> </div> )} </div> <div className='space-y-1'> - <Label>Email address</Label> + <Label>{accountCopy.emailAddress}</Label> <div className='rounded-md border bg-muted/40 px-3 py-2 text-sm text-muted-foreground'> {email || '—'} </div> - <p className='text-muted-foreground text-xs'>Email changes are handled by support.</p> + <p className='text-muted-foreground text-xs'>{accountCopy.emailHint}</p> </div> </div> <div className='rounded-sm border bg-muted/30 px-4 py-4'> <div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'> <div> - <Label className='text-sm font-semibold'>Password reset</Label> - <p className='text-muted-foreground text-sm'>We’ll email you a secure link.</p> + <Label className='text-sm font-semibold'>{accountCopy.passwordReset}</Label> + <p className='text-muted-foreground text-sm'> + {accountCopy.passwordResetDescription} + </p> </div> <Button type='button' @@ -532,10 +541,10 @@ export function AccountSettings() { {isSendingReset ? ( <> <Loader2 className='mr-2 h-4 w-4 animate-spin' /> - Sending… + {accountCopy.sending} </> ) : ( - 'Send link' + accountCopy.sendLink )} </Button> </div> @@ -555,8 +564,8 @@ export function AccountSettings() { <div className='px-6 pb-6'> <Card className='border-none shadow-none'> <CardHeader className='space-y-1 pb-5'> - <CardTitle className='text-lg font-semibold'>Privacy</CardTitle> - <p className='text-muted-foreground text-sm'>Manage how your data is collected.</p> + <CardTitle className='text-lg font-semibold'>{accountCopy.privacy}</CardTitle> + <p className='text-muted-foreground text-sm'>{accountCopy.privacyDescription}</p> </CardHeader> <CardContent> <TooltipProvider> @@ -564,7 +573,7 @@ export function AccountSettings() { <div className='flex items-center justify-between'> <div className='flex items-center gap-2'> <Label htmlFor='telemetry' className='font-normal'> - Allow anonymous telemetry + {accountCopy.telemetry.label} </Label> <Tooltip> <TooltipTrigger asChild> @@ -572,7 +581,7 @@ export function AccountSettings() { variant='ghost' size='sm' className='h-7 p-1 text-gray-500' - aria-label='Learn more about telemetry data collection' + aria-label={accountCopy.telemetry.tooltipLabel} disabled={isTelemetrySettingsLoading || isTelemetryLoading} > <Info className='h-5 w-5' /> @@ -580,7 +589,7 @@ export function AccountSettings() { </TooltipTrigger> <TooltipContent side='top' className='max-w-[300px] p-3'> <p className='text-sm'> - We collect anonymous data about feature usage, performance, and errors to improve the application. + {accountCopy.telemetry.tooltipBody} </p> </TooltipContent> </Tooltip> @@ -593,9 +602,7 @@ export function AccountSettings() { /> </div> <p className='text-muted-foreground text-xs'> - We use OpenTelemetry to collect anonymous usage data to improve TradingGoose. All data is - collected in accordance with our privacy policy, and you can opt-out at any time. - This setting applies to your account on all devices. + {accountCopy.telemetry.body} </p> </div> </TooltipProvider> diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx index b7a342be0..5ebe79488 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx @@ -1,12 +1,13 @@ 'use client' import Image from 'next/image' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import imageCompression from 'browser-image-compression' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { X } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -20,6 +21,8 @@ import { import { Textarea } from '@/components/ui/textarea' import { cn } from '@/lib/utils' import { createLogger } from '@/lib/logs/console/logger' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { SettingsModal } from '../../settings-modal' const helpLogger = createLogger('HelpModal') @@ -31,15 +34,11 @@ const SCROLL_DELAY_MS = 100 const SUCCESS_RESET_DELAY_MS = 2000 const DEFAULT_REQUEST_TYPE = 'bug' -const formSchema = z.object({ - subject: z.string().min(1, 'Subject is required'), - message: z.string().min(1, 'Message is required'), - type: z.enum(['bug', 'feedback', 'feature_request', 'other'], { - required_error: 'Please select a request type', - }), -}) - -type FormValues = z.infer<typeof formSchema> +type FormValues = { + subject: string + message: string + type: 'bug' | 'feedback' | 'feature_request' | 'other' +} interface ImageWithPreview extends File { preview: string @@ -51,6 +50,20 @@ export interface HelpModalProps { } export function HelpModal({ open, onOpenChange }: HelpModalProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.settingsModal + const helpCopy = copy.help + const formSchema = useMemo( + () => + z.object({ + subject: z.string().min(1, helpCopy.errorMessages.subjectRequired), + message: z.string().min(1, helpCopy.errorMessages.messageRequired), + type: z.enum(['bug', 'feedback', 'feature_request', 'other'], { + required_error: helpCopy.errorMessages.requestTypeRequired, + }), + }), + [helpCopy] + ) const fileInputRef = useRef<HTMLInputElement>(null) const scrollContainerRef = useRef<HTMLDivElement>(null) const dropZoneRef = useRef<HTMLDivElement>(null) @@ -166,14 +179,14 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { for (const file of Array.from(files)) { if (file.size > MAX_FILE_SIZE) { - setImageError(`File ${file.name} is too large. Maximum size is 20MB.`) + setImageError(formatTemplate(helpCopy.errorMessages.fileTooLarge, { name: file.name })) hasError = true continue } if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { setImageError( - `File ${file.name} has an unsupported format. Please use JPEG, PNG, WebP, or GIF.` + formatTemplate(helpCopy.errorMessages.unsupportedFormat, { name: file.name }) ) hasError = true continue @@ -192,7 +205,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { } } catch (error) { helpLogger.error('Error processing images:', { error }) - setImageError('An error occurred while processing images. Please try again.') + setImageError(helpCopy.errorMessages.processing) } finally { setIsProcessing(false) @@ -201,7 +214,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { } } }, - [compressImage] + [compressImage, helpCopy] ) const handleFileChange = useCallback( @@ -273,7 +286,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { if (!response.ok) { const errorData = await response.json() - throw new Error(errorData.error || 'Failed to submit help request') + throw new Error(errorData.error || helpCopy.errorMessages.submitFailed) } setSubmitStatus('success') @@ -284,12 +297,12 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { } catch (error) { helpLogger.error('Error submitting help request:', { error }) setSubmitStatus('error') - setErrorMessage(error instanceof Error ? error.message : 'An unknown error occurred') + setErrorMessage(error instanceof Error ? error.message : helpCopy.errorMessages.unknown) } finally { setIsSubmitting(false) } }, - [images, reset] + [helpCopy, images, reset] ) const handleClose = useCallback(() => { @@ -300,7 +313,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { <SettingsModal open={open} onOpenChange={onOpenChange} - title='Help & Support' + title={copy.titles.help} contentClassName='flex h-[75vh] flex-col p-0' > <form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'> @@ -308,7 +321,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { <div className='px-6'> <div className='space-y-4'> <div className='space-y-1'> - <Label htmlFor='type'>Request</Label> + <Label htmlFor='type'>{helpCopy.requestType}</Label> <Select defaultValue={DEFAULT_REQUEST_TYPE} onValueChange={(value) => setValue('type', value as FormValues['type'])} @@ -317,23 +330,25 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { id='type' className={cn('h-9 rounded-sm', errors.type && 'border-red-500')} > - <SelectValue placeholder='Select a request type' /> + <SelectValue placeholder={helpCopy.requestTypePlaceholder} /> </SelectTrigger> <SelectContent> - <SelectItem value='bug'>Bug Report</SelectItem> - <SelectItem value='feedback'>Feedback</SelectItem> - <SelectItem value='feature_request'>Feature Request</SelectItem> - <SelectItem value='other'>Other</SelectItem> + <SelectItem value='bug'>{helpCopy.requestTypes.bug}</SelectItem> + <SelectItem value='feedback'>{helpCopy.requestTypes.feedback}</SelectItem> + <SelectItem value='feature_request'> + {helpCopy.requestTypes.feature_request} + </SelectItem> + <SelectItem value='other'>{helpCopy.requestTypes.other}</SelectItem> </SelectContent> </Select> {errors.type && <p className='mt-1 text-red-500 text-sm'>{errors.type.message}</p>} </div> <div className='space-y-1'> - <Label htmlFor='subject'>Subject</Label> + <Label htmlFor='subject'>{helpCopy.subject}</Label> <Input id='subject' - placeholder='Brief description of your request' + placeholder={helpCopy.subjectPlaceholder} {...register('subject')} className={cn('h-9 rounded-sm', errors.subject && 'border-red-500')} /> @@ -343,10 +358,10 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { </div> <div className='space-y-1'> - <Label htmlFor='message'>Message</Label> + <Label htmlFor='message'>{helpCopy.message}</Label> <Textarea id='message' - placeholder='Please provide details about your request...' + placeholder={helpCopy.messagePlaceholder} rows={6} {...register('message')} className={cn('rounded-sm', errors.message && 'border-red-500')} @@ -357,7 +372,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { </div> <div className='mt-6 space-y-1'> - <Label>Attach Images (Optional)</Label> + <Label>{helpCopy.attachments}</Label> <div ref={dropZoneRef} onDragEnter={handleDragEnter} @@ -379,24 +394,29 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { multiple /> <p className='text-sm'> - {isDragging ? 'Drop images here!' : 'Drop images here or click to browse'} + {isDragging ? helpCopy.dropImages : helpCopy.dropImagesBrowse} </p> <p className='mt-1 text-muted-foreground text-xs'> - JPEG, PNG, WebP, GIF (max 20MB each) + {helpCopy.imageHint} </p> </div> {imageError && <p className='mt-1 text-red-500 text-sm'>{imageError}</p>} - {isProcessing && <p className='text-muted-foreground text-sm'>Processing images...</p>} + {isProcessing && <p className='text-muted-foreground text-sm'>{helpCopy.processing}</p>} </div> {images.length > 0 && ( <div className='space-y-1'> - <Label>Uploaded Images</Label> + <Label>{helpCopy.uploadedImages}</Label> <div className='grid grid-cols-2 gap-4'> {images.map((image, index) => ( <div key={index} className='group relative overflow-hidden rounded-md border'> <div className='relative aspect-video'> - <Image src={image.preview} alt={`Preview ${index + 1}`} fill className='object-cover' /> + <Image + src={image.preview} + alt={`${helpCopy.uploadedImages} ${index + 1}`} + fill + className='object-cover' + /> <div className='absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100' onClick={() => removeImage(index)} @@ -417,7 +437,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { <div className='border-t bg-background'> <div className='flex w-full items-center justify-between px-6 py-4'> <Button variant='outline' onClick={handleClose} type='button'> - Cancel + {helpCopy.cancel} </Button> <Button type='submit' @@ -435,12 +455,12 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { )} > {isSubmitting - ? 'Submitting...' + ? helpCopy.submitting : submitStatus === 'error' - ? 'Error' + ? helpCopy.error : submitStatus === 'success' - ? 'Success' - : 'Submit'} + ? helpCopy.success + : helpCopy.submit} </Button> </div> {submitStatus === 'error' ? ( diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/service/service.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/service/service.tsx index 859ea8cba..4fc0d1adb 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/service/service.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/service/service.tsx @@ -1,5 +1,6 @@ 'use client' +import { useLocale } from 'next-intl' import { useState } from 'react' import { Check, Copy, Plus } from 'lucide-react' import { @@ -16,6 +17,8 @@ import { } from '@/components/ui' import { isHosted } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { type ServiceApiKey, type ServiceKeyKind, @@ -26,23 +29,6 @@ import { const logger = createLogger('ServiceApiKeysSettings') -const SERVICE_COPY: Record< - ServiceKeyKind, - { - title: string - description: string - } -> = { - copilot: { - title: 'Copilot', - description: 'Generate keys for Copilot API access.', - }, - market: { - title: 'Market', - description: 'Generate keys for Market API access.', - }, -} - export function Service() { if (!isHosted) { return null @@ -59,7 +45,9 @@ export function Service() { } function ServiceKeyPanel({ service }: { service: ServiceKeyKind }) { - const copy = SERVICE_COPY[service] + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.settingsModal.service[service] + const serviceCopy = getPublicCopy(locale).workspace.settingsModal.service const { data: keys = [], isPending: isKeysPending } = useServiceKeys(service) const generateKey = useGenerateServiceKey(service) const deleteKeyMutation = useDeleteServiceKey(service) @@ -115,7 +103,7 @@ function ServiceKeyPanel({ service }: { service: ServiceKeyKind }) { disabled={isKeysPending || generateKey.isPending || deleteKeyMutation.isPending} > <Plus className='h-3.5 w-3.5 stroke-[2px]' /> - Create + {serviceCopy.create} </Button> </div> @@ -126,7 +114,7 @@ function ServiceKeyPanel({ service }: { service: ServiceKeyKind }) { <ServiceKeySkeleton /> </> ) : keys.length === 0 ? ( - <div className='py-3 text-center text-muted-foreground text-xs'>No API keys yet</div> + <div className='py-3 text-center text-muted-foreground text-xs'>{serviceCopy.noKeys}</div> ) : ( keys.map((key) => ( <div key={key.id} className='flex items-center justify-between gap-4'> @@ -142,7 +130,7 @@ function ServiceKeyPanel({ service }: { service: ServiceKeyKind }) { }} className='h-8 text-muted-foreground hover:text-foreground' > - Delete + {serviceCopy.delete} </Button> </div> )) @@ -161,11 +149,8 @@ function ServiceKeyPanel({ service }: { service: ServiceKeyKind }) { > <AlertDialogContent className='rounded-md sm:max-w-lg'> <AlertDialogHeader> - <AlertDialogTitle>Your API key has been created</AlertDialogTitle> - <AlertDialogDescription> - This is the only time you will see your API key.{' '} - <span className='font-semibold'>Copy it now and store it securely.</span> - </AlertDialogDescription> + <AlertDialogTitle>{serviceCopy.generateSuccessTitle}</AlertDialogTitle> + <AlertDialogDescription>{serviceCopy.generateSuccessDescription}</AlertDialogDescription> </AlertDialogHeader> {newKey ? ( @@ -184,7 +169,7 @@ function ServiceKeyPanel({ service }: { service: ServiceKeyKind }) { ) : ( <Copy className='!h-3.5 !w-3.5' /> )} - <span className='sr-only'>Copy to clipboard</span> + <span className='sr-only'>{serviceCopy.copyToClipboard}</span> </Button> </div> ) : null} @@ -194,16 +179,13 @@ function ServiceKeyPanel({ service }: { service: ServiceKeyKind }) { <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> <AlertDialogContent className='rounded-md sm:max-w-md'> <AlertDialogHeader> - <AlertDialogTitle>Delete API key?</AlertDialogTitle> - <AlertDialogDescription> - Deleting this API key will immediately revoke access for any integrations using it.{' '} - <span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span> - </AlertDialogDescription> + <AlertDialogTitle>{serviceCopy.deleteTitle}</AlertDialogTitle> + <AlertDialogDescription>{serviceCopy.deleteDescription}</AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter className='flex'> <AlertDialogCancel className='h-9 w-full rounded-sm' onClick={() => setDeleteKey(null)}> - Cancel + {serviceCopy.cancel} </AlertDialogCancel> <AlertDialogAction onClick={() => { @@ -216,7 +198,7 @@ function ServiceKeyPanel({ service }: { service: ServiceKeyKind }) { className='h-9 w-full rounded-sm bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600' disabled={deleteKeyMutation.isPending} > - Delete + {serviceCopy.delete} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/sso/sso.test.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/sso/sso.test.tsx index 71903f673..71e8f030b 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/sso/sso.test.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/sso/sso.test.tsx @@ -6,6 +6,10 @@ const mockUseSession = vi.fn() const mockUseOrganizations = vi.fn() const mockUseOrganizationBilling = vi.fn() +vi.mock('next-intl', () => ({ + useLocale: () => 'en', +})) + vi.mock('@/components/ui', () => ({ Alert: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, AlertDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/sso/sso.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/sso/sso.tsx index 681e57792..c7c47748d 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/sso/sso.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/sso/sso.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react' +import { useLocale } from 'next-intl' import { Alert, AlertDescription, Button, Input, Label } from '@/components/ui' import { Skeleton } from '@/components/ui/skeleton' import { useSession } from '@/lib/auth-client' @@ -11,6 +12,8 @@ import { getUserRole } from '@/lib/organization/helpers' import { getBaseUrl } from '@/lib/urls/utils' import { cn } from '@/lib/utils' import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organization' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' const logger = createLogger('SSO') @@ -73,6 +76,8 @@ const getSsoCallbackUrl = (providerId: string, providerType: 'oidc' | 'saml') => }/${providerId}` export function SSO() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.settingsModal.sso const { data: session } = useSession() const { data: organizationsData } = useOrganizations() const activeOrganization = organizationsData?.activeOrganization @@ -157,7 +162,7 @@ export function SSO() { errorData?.details || errorData?.error || response.statusText || - 'Failed to load SSO providers' + copy.providerLoadError ) } @@ -171,7 +176,7 @@ export function SSO() { if (!cancelled) { setProviders([]) setProviderLoadError( - error instanceof Error ? error.message : 'Failed to load SSO provider configuration' + error instanceof Error ? error.message : copy.providerLoadError ) } } finally { @@ -193,7 +198,7 @@ export function SSO() { <div className='flex h-full items-center justify-center p-6'> <Alert> <AlertDescription> - You must be part of an organization to configure Single Sign-On. + {copy.selectOrganization} </AlertDescription> </Alert> </div> @@ -205,7 +210,7 @@ export function SSO() { <div className='flex h-full items-center justify-center p-6'> <Alert> <AlertDescription> - Only organization owners and admins can configure Single Sign-On settings. + {copy.onlyAdmins} </AlertDescription> </Alert> </div> @@ -216,7 +221,7 @@ export function SSO() { return ( <div className='flex h-full items-center justify-center p-6'> <Alert> - <AlertDescription>Single Sign-On is not enabled for this billing tier.</AlertDescription> + <AlertDescription>{copy.disabledTier}</AlertDescription> </Alert> </div> ) @@ -224,38 +229,38 @@ export function SSO() { const validateProviderId = (value: string): string[] => { const out: string[] = [] - if (!value || !value.trim()) out.push('Provider ID is required.') - if (!/^[-a-z0-9]+$/i.test(value.trim())) out.push('Use letters, numbers, and dashes only.') + if (!value || !value.trim()) out.push(copy.validation.providerIdRequired) + if (!/^[-a-z0-9]+$/i.test(value.trim())) out.push(copy.validation.providerIdPattern) return out } const validateIssuerUrl = (value: string): string[] => { const out: string[] = [] - if (!value || !value.trim()) return ['Issuer URL is required.'] + if (!value || !value.trim()) return [copy.validation.issuerUrlRequired] try { const url = new URL(value.trim()) const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1' if (url.protocol !== 'https:' && !isLocalhost) { - out.push('Issuer URL must use HTTPS.') + out.push(copy.validation.issuerUrlHttps) } } catch { - out.push('Enter a valid issuer URL like https://your-identity-provider.com/oauth2/default') + out.push(copy.validation.issuerUrlValid) } return out } const validateDomain = (value: string): string[] => { const out: string[] = [] - if (!value || !value.trim()) return ['Domain is required.'] - if (/^https?:\/\//i.test(value.trim())) out.push('Do not include protocol (https://).') + if (!value || !value.trim()) return [copy.validation.domainRequired] + if (/^https?:\/\//i.test(value.trim())) out.push(copy.validation.domainNoProtocol) if (!/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(value.trim())) - out.push('Enter a valid domain like your-domain.identityprovider.com') + out.push(copy.validation.domainValid) return out } const validateRequired = (label: string, value: string): string[] => { const out: string[] = [] - if (!value || !value.trim()) out.push(`${label} is required.`) + if (!value || !value.trim()) out.push(formatTemplate(copy.validation.fieldRequired, { field: label })) return out } @@ -275,17 +280,17 @@ export function SSO() { } if (data.providerType === 'oidc') { - newErrors.clientId = validateRequired('Client ID', data.clientId) - newErrors.clientSecret = validateRequired('Client Secret', data.clientSecret) + newErrors.clientId = validateRequired(copy.clientId, data.clientId) + newErrors.clientSecret = validateRequired(copy.clientSecret, data.clientSecret) if (!data.scopes || !data.scopes.trim()) { - newErrors.scopes = ['Scopes are required for OIDC providers'] + newErrors.scopes = [copy.validation.scopesRequired] } } else if (data.providerType === 'saml') { newErrors.entryPoint = validateIssuerUrl(data.entryPoint || '') if (!newErrors.entryPoint.length && !data.entryPoint) { - newErrors.entryPoint = ['Entry Point URL is required for SAML providers'] + newErrors.entryPoint = [copy.validation.entryPointRequired] } - newErrors.cert = validateRequired('Certificate', data.cert) + newErrors.cert = validateRequired(copy.certificate, data.cert) } setErrors(newErrors) @@ -372,7 +377,7 @@ export function SSO() { if (!response.ok) { const errorData = await response.json() - throw new Error(errorData.details || errorData.error || 'Failed to configure SSO provider') + throw new Error(errorData.details || errorData.error || copy.providerError) } const result = await response.json() @@ -402,7 +407,7 @@ export function SSO() { errorData?.details || errorData?.error || providersResponse.statusText || - 'Failed to reload SSO providers' + copy.reloadError ) } @@ -411,7 +416,7 @@ export function SSO() { setShowConfigForm(false) } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error occurred' + const message = err instanceof Error ? err.message : copy.providerError setError(message) logger.error('Failed to configure SSO provider', { error: err }) } finally { @@ -496,7 +501,7 @@ export function SSO() { <div key={provider.id} className='rounded-lg border border-border p-6'> <div className='flex items-start justify-between gap-3'> <div className='flex-1'> - <h3 className='font-medium text-base'>Single Sign-On Provider</h3> + <h3 className='font-medium text-base'>{copy.providerStatus}</h3> <p className='mt-1 text-muted-foreground text-sm'> {provider.providerId} • {provider.domain} </p> @@ -506,20 +511,20 @@ export function SSO() { <div className='mt-4 border-border border-t pt-4'> <div className='grid grid-cols-2 gap-4 text-sm'> <div> - <span className='font-medium text-muted-foreground'>Issuer URL</span> + <span className='font-medium text-muted-foreground'>{copy.issuerUrl}</span> <p className='mt-1 break-all font-mono text-foreground text-xs'> {provider.issuer} </p> </div> <div> - <span className='font-medium text-muted-foreground'>Provider ID</span> + <span className='font-medium text-muted-foreground'>{copy.providerId}</span> <p className='mt-1 text-foreground'>{provider.providerId}</p> </div> </div> <div className='mt-4'> <span className='font-medium text-muted-foreground text-sm'> - Callback URL + {copy.callbackUrl} </span> <div className='relative mt-2'> <Input @@ -536,7 +541,7 @@ export function SSO() { setCopied(true) setTimeout(() => setCopied(false), 1500) }} - aria-label='Copy callback URL' + aria-label={copy.copyCallbackUrl} className='-translate-y-1/2 absolute top-1/2 right-3 rounded p-1 text-muted-foreground transition hover:text-foreground' > {copied ? ( @@ -559,7 +564,7 @@ export function SSO() { <input type='text' name='hidden' style={{ display: 'none' }} autoComplete='false' /> {/* Provider Type Selection */} <div className='space-y-1'> - <Label>Provider Type</Label> + <Label>{copy.providerType}</Label> <div className='flex rounded-md border border-input bg-background p-1'> <button type='button' @@ -588,13 +593,13 @@ export function SSO() { </div> <p className='text-muted-foreground text-xs'> {formData.providerType === 'oidc' - ? 'OpenID Connect (Okta, Azure AD, Auth0, etc.)' - : 'Security Assertion Markup Language (ADFS, Shibboleth, etc.)'} + ? copy.providerTypeDescriptions.oidc + : copy.providerTypeDescriptions.saml} </p> </div> <div className='space-y-1'> - <Label htmlFor='provider-id'>Provider ID</Label> + <Label htmlFor='provider-id'>{copy.providerId}</Label> <select id='provider-id' value={formData.providerId} @@ -606,7 +611,7 @@ export function SSO() { 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' )} > - <option value=''>Select a provider ID</option> + <option value=''>{copy.selectProviderId}</option> {TRUSTED_SSO_PROVIDERS.map((providerId) => ( <option key={providerId} value={providerId}> {providerId} @@ -619,16 +624,16 @@ export function SSO() { </div> )} <p className='text-muted-foreground text-xs'> - Select a pre-configured provider ID from the trusted providers list + {copy.selectProviderHelp} </p> </div> <div className='space-y-1'> - <Label htmlFor='issuer-url'>Issuer URL</Label> + <Label htmlFor='issuer-url'>{copy.issuerUrl}</Label> <Input id='issuer-url' type='url' - placeholder='Enter Issuer URL' + placeholder={copy.issuerUrlPlaceholder} value={formData.issuerUrl} name='sso_issuer_endpoint' autoComplete='off' @@ -649,15 +654,15 @@ export function SSO() { <p>{errors.issuerUrl.join(' ')}</p> </div> )} - <p className='text-muted-foreground text-xs' /> + <p className='text-muted-foreground text-xs'>{copy.issuerUrlHelp}</p> </div> <div className='space-y-1'> - <Label htmlFor='domain'>Domain</Label> + <Label htmlFor='domain'>{copy.domain}</Label> <Input id='domain' type='text' - placeholder='Enter Domain' + placeholder={copy.domainPlaceholder} value={formData.domain} name='sso_identity_domain' autoComplete='off' @@ -684,11 +689,11 @@ export function SSO() { {formData.providerType === 'oidc' ? ( <> <div className='space-y-1'> - <Label htmlFor='client-id'>Client ID</Label> + <Label htmlFor='client-id'>{copy.clientId}</Label> <Input id='client-id' type='text' - placeholder='Enter Client ID' + placeholder={copy.clientIdPlaceholder} value={formData.clientId} name='sso_client_identifier' autoComplete='off' @@ -712,12 +717,12 @@ export function SSO() { </div> <div className='space-y-1'> - <Label htmlFor='client-secret'>Client Secret</Label> + <Label htmlFor='client-secret'>{copy.clientSecret}</Label> <div className='relative'> <Input id='client-secret' type={showClientSecret ? 'text' : 'password'} - placeholder='Enter Client Secret' + placeholder={copy.clientSecretPlaceholder} value={formData.clientSecret} name='sso_client_key' autoComplete='new-password' @@ -741,9 +746,7 @@ export function SSO() { type='button' onClick={() => setShowClientSecret((s) => !s)} className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700' - aria-label={ - showClientSecret ? 'Hide client secret' : 'Show client secret' - } + aria-label={showClientSecret ? copy.hideClientSecret : copy.showClientSecret} > {showClientSecret ? <EyeOff size={18} /> : <Eye size={18} />} </button> @@ -756,11 +759,11 @@ export function SSO() { </div> <div className='space-y-1'> - <Label htmlFor='scopes'>Scopes</Label> + <Label htmlFor='scopes'>{copy.scopes}</Label> <Input id='scopes' type='text' - placeholder='openid,profile,email' + placeholder={copy.scopesPlaceholder} value={formData.scopes} autoComplete='off' autoCapitalize='none' @@ -779,18 +782,18 @@ export function SSO() { </div> )} <p className='text-muted-foreground text-xs'> - Comma-separated list of OIDC scopes to request + {copy.scopesDescription} </p> </div> </> ) : ( <> <div className='space-y-1'> - <Label htmlFor='entry-point'>Entry Point URL</Label> + <Label htmlFor='entry-point'>{copy.entryPoint}</Label> <Input id='entry-point' type='url' - placeholder='Enter Entry Point URL' + placeholder={copy.entryPointPlaceholder} value={formData.entryPoint} autoComplete='off' autoCapitalize='none' @@ -808,14 +811,14 @@ export function SSO() { <p>{errors.entryPoint.join(' ')}</p> </div> )} - <p className='text-muted-foreground text-xs' /> + <p className='text-muted-foreground text-xs'>{copy.entryPointDescription}</p> </div> <div className='space-y-1'> - <Label htmlFor='cert'>Identity Provider Certificate</Label> + <Label htmlFor='cert'>{copy.certificate}</Label> <textarea id='cert' - placeholder='-----BEGIN CERTIFICATE----- MIIDBjCCAe4CAQAwDQYJKoZIhvcNAQEFBQAwEjEQMA... -----END CERTIFICATE-----' + placeholder={copy.certificatePlaceholder} value={formData.cert} autoComplete='off' autoCapitalize='none' @@ -834,7 +837,7 @@ export function SSO() { <p>{errors.cert.join(' ')}</p> </div> )} - <p className='text-muted-foreground text-xs' /> + <p className='text-muted-foreground text-xs'>{copy.certificateDescription}</p> </div> {/* Advanced SAML Options */} @@ -855,17 +858,17 @@ export function SSO() { formData.showAdvanced && 'rotate-180' )} /> - Advanced SAML Options + {copy.advancedOptions} </button> {formData.showAdvanced && ( <> <div className='space-y-1'> - <Label htmlFor='audience'>Audience (Entity ID)</Label> + <Label htmlFor='audience'>{copy.audience}</Label> <Input id='audience' type='text' - placeholder='Enter Audience' + placeholder={copy.audiencePlaceholder} value={formData.audience} autoComplete='off' autoCapitalize='none' @@ -873,17 +876,15 @@ export function SSO() { onChange={(e) => handleInputChange('audience', e.target.value)} className='rounded-md shadow-sm' /> - <p className='text-muted-foreground text-xs'> - The SAML audience restriction (optional, defaults to app URL) - </p> + <p className='text-muted-foreground text-xs'>{copy.audienceDescription}</p> </div> <div className='space-y-1'> - <Label htmlFor='callback-url'>Callback URL Override</Label> + <Label htmlFor='callback-url'>{copy.callbackUrlOverride}</Label> <Input id='callback-url' type='url' - placeholder='Enter Callback URL' + placeholder={copy.callbackUrlPlaceholder} value={formData.callbackUrl} autoComplete='off' autoCapitalize='none' @@ -891,9 +892,7 @@ export function SSO() { onChange={(e) => handleInputChange('callbackUrl', e.target.value)} className='rounded-md shadow-sm' /> - <p className='text-muted-foreground text-xs'> - Custom SAML callback URL (optional, auto-generated if empty) - </p> + <p className='text-muted-foreground text-xs'>{copy.callbackUrlDescription}</p> </div> <div className='flex items-center space-x-2'> @@ -910,15 +909,15 @@ export function SSO() { className='rounded' /> <Label htmlFor='want-assertions-signed' className='text-sm'> - Require signed SAML assertions + {copy.requireSignedAssertions} </Label> </div> <div className='space-y-1'> - <Label htmlFor='idp-metadata'>Identity Provider Metadata XML</Label> + <Label htmlFor='idp-metadata'>{copy.metadataXml}</Label> <textarea id='idp-metadata' - placeholder='<?xml version="1.0" encoding="UTF-8"?> <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"> ... </md:EntityDescriptor>' + placeholder={copy.metadataPlaceholder} value={formData.idpMetadata} autoComplete='off' autoCapitalize='none' @@ -927,10 +926,7 @@ export function SSO() { className='min-h-[100px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100' rows={4} /> - <p className='text-muted-foreground text-xs'> - Paste the complete IDP metadata XML from your identity provider for - advanced configuration - </p> + <p className='text-muted-foreground text-xs'>{copy.metadataDescription}</p> </div> </> )} @@ -943,14 +939,14 @@ export function SSO() { className='w-full rounded-md' disabled={isLoading || hasAnyErrors(errors) || !isFormValid()} > - {isLoading ? 'Configuring...' : 'Configure SSO Provider'} + {isLoading ? copy.configuring : copy.configureProvider} </Button> </form> <div className='space-y-1'> - <Label htmlFor='callback-url'>Callback URL</Label> + <Label htmlFor='callback-url'>{copy.callbackUrl}</Label> <p className='text-muted-foreground text-xs'> - Configure this URL in your identity provider as the callback/redirect URI + {copy.callbackUrlHelp} </p> <div className='relative'> <Input @@ -966,7 +962,7 @@ export function SSO() { <button type='button' onClick={copyCallback} - aria-label='Copy callback URL' + aria-label={copy.copyCallbackUrl} className='-translate-y-1/2 absolute top-1/2 right-3 rounded p-1 text-muted-foreground transition hover:text-foreground' > {copied ? ( diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx index 8539c8a5a..0a70bad77 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx @@ -2,11 +2,14 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import { Check, Pencil, X } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { useUpdateOrganizationUsageLimit } from '@/hooks/queries/organization' import { useUpdateUsageLimit } from '@/hooks/queries/subscription' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' const logger = createLogger('UsageLimit') @@ -37,6 +40,8 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>( }, ref ) => { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.settingsModal.subscription.limit const [inputValue, setInputValue] = useState(currentLimit.toString()) const [hasError, setHasError] = useState(false) const [errorType, setErrorType] = useState<'general' | 'belowUsage' | null>(null) @@ -230,7 +235,7 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>( ) : ( <Pencil className='!h-3 !w-3' /> )} - <span className='sr-only'>{isEditing ? 'Save limit' : 'Edit limit'}</span> + <span className='sr-only'>{isEditing ? copy.save : copy.edit}</span> </Button> )} </div> diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/components/workspace-billing-owner.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/components/workspace-billing-owner.tsx index cf9b47b4f..2c6cd37a7 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/components/workspace-billing-owner.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/components/workspace-billing-owner.tsx @@ -1,6 +1,7 @@ 'use client' import { useState } from 'react' +import { useLocale } from 'next-intl' import { useParams } from 'next/navigation' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Label } from '@/components/ui/label' @@ -23,6 +24,8 @@ import { useWorkspaceSettings, type WorkspaceBillingOwner, } from '@/hooks/queries/workspace' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' const logger = createLogger('WorkspaceBillingOwnerEditor') @@ -31,6 +34,8 @@ function getBillingOwnerValue(billingOwner: WorkspaceBillingOwner): string { } export function WorkspaceBillingOwnerEditor() { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.settingsModal.subscription.billingOwner const { data: session } = useSession() const { data: organizationsData } = useOrganizations() const params = useParams<{ workspaceId?: string | string[] }>() @@ -68,7 +73,7 @@ export function WorkspaceBillingOwnerEditor() { try { if (value === 'organization') { if (!activeOrganization?.id) { - throw new Error('No active organization is available for billing ownership') + throw new Error(copy.noActiveOrganization) } await assignWorkspaceToOrganization.mutateAsync({ @@ -79,12 +84,12 @@ export function WorkspaceBillingOwnerEditor() { } if (!value.startsWith('user:')) { - throw new Error('Invalid billing owner selection') + throw new Error(copy.invalidSelection) } const userId = value.slice('user:'.length) if (!userId) { - throw new Error('Invalid billing owner selection') + throw new Error(copy.invalidSelection) } await updateWorkspaceSettings.mutateAsync({ @@ -95,7 +100,7 @@ export function WorkspaceBillingOwnerEditor() { }, }) } catch (cause) { - const message = cause instanceof Error ? cause.message : 'Failed to update billing owner' + const message = cause instanceof Error ? cause.message : copy.failedToUpdate logger.error('Failed to update workspace billing owner', { error: cause, workspaceId: workspace.id, @@ -107,22 +112,20 @@ export function WorkspaceBillingOwnerEditor() { return ( <div className='space-y-3 rounded-sm border bg-background p-4 shadow-xs'> <div className='space-y-1'> - <h4 className='font-medium text-sm'>Billing owner</h4> - <p className='text-muted-foreground text-xs'> - Choose which admin account or organization pays for this workspace. - </p> + <h4 className='font-medium text-sm'>{copy.title}</h4> + <p className='text-muted-foreground text-xs'>{copy.description}</p> </div> {error ? ( <Alert variant='destructive' className='rounded-sm'> - <AlertTitle>Error</AlertTitle> + <AlertTitle>{copy.error}</AlertTitle> <AlertDescription>{error}</AlertDescription> </Alert> ) : null} <div className='space-y-2'> <Label htmlFor='workspace-billing-owner' className='font-medium text-sm'> - Owner + {copy.ownerLabel} </Label> <Select value={currentValue} @@ -134,7 +137,7 @@ export function WorkspaceBillingOwnerEditor() { } > <SelectTrigger id='workspace-billing-owner' className='rounded-sm'> - <SelectValue placeholder='Select billing owner' /> + <SelectValue placeholder={copy.selectPlaceholder} /> </SelectTrigger> <SelectContent> {admins.map((admin) => ( @@ -149,18 +152,17 @@ export function WorkspaceBillingOwnerEditor() { ) : null} {activeOrganization?.id ? ( <SelectItem value='organization' disabled={!canAssignOrganizationBilling}> - {activeOrganization.name || 'Organization'} + {activeOrganization.name || copy.organization} </SelectItem> ) : workspace.billingOwner.type === 'organization' ? ( <SelectItem value='organization' disabled> - Organization + {copy.organization} </SelectItem> ) : null} </SelectContent> </Select> <p className='text-muted-foreground text-xs'> - User billing must point at a workspace admin. Organization billing requires an active - organization billing tier. + {copy.billingNotice} </p> </div> </div> diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/payg-ui.test.ts b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/payg-ui.test.ts index 36d741e13..1f08bd219 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/payg-ui.test.ts +++ b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/payg-ui.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' import { getPersonalPaygUiState, shouldOpenBillingPortalForPaygActivationError } from './payg-ui' describe('getPersonalPaygUiState', () => { + const labels = getPublicCopy('en').workspace.settingsModal.subscription.badges const base = { billingBlocked: false, hasPaymentMethodOnFile: true, @@ -12,12 +14,12 @@ describe('getPersonalPaygUiState', () => { tierCanEditUsageLimit: true, } as const - it.each([ + const testCases = [ [ 'shows resolve payment for blocked billing', { billingBlocked: true }, { - badgeText: 'Resolve Payment', + badgeText: labels.resolvePayment, primaryAction: 'resolve_payment', showBadge: true, showUsageLimitControl: false, @@ -27,7 +29,7 @@ describe('getPersonalPaygUiState', () => { 'shows add payment method before activation', { hasPaymentMethodOnFile: false, hasStripeSubscription: false, tierCanEditUsageLimit: false }, { - badgeText: 'Add Payment Method', + badgeText: labels.addPaymentMethod, primaryAction: 'add_payment_method', showBadge: true, showUsageLimitControl: false, @@ -37,7 +39,7 @@ describe('getPersonalPaygUiState', () => { 'shows activate PAYG once a payment method exists', { hasStripeSubscription: false, tierCanEditUsageLimit: false }, { - badgeText: 'Activate PAYG', + badgeText: labels.activatePayg, primaryAction: 'activate_payg', showBadge: true, showUsageLimitControl: false, @@ -47,7 +49,7 @@ describe('getPersonalPaygUiState', () => { 'shows increase limit when usage can be edited', { canEditUsageLimit: true }, { - badgeText: 'Increase Limit', + badgeText: labels.increaseLimit, primaryAction: 'increase_limit', showBadge: true, showUsageLimitControl: true, @@ -57,7 +59,7 @@ describe('getPersonalPaygUiState', () => { 'shows manage billing for fixed Stripe-backed states', { subscriptionStatus: 'trialing', tierCanEditUsageLimit: false }, { - badgeText: 'Manage Billing', + badgeText: labels.manageBilling, primaryAction: 'manage_billing', showBadge: true, showUsageLimitControl: false, @@ -72,14 +74,22 @@ describe('getPersonalPaygUiState', () => { tierCanEditUsageLimit: false, }, { - badgeText: 'Add Payment Method', + badgeText: labels.addPaymentMethod, primaryAction: 'add_payment_method', showBadge: false, showUsageLimitControl: false, }, ], - ])('%s', (_name, overrides, expected) => { - expect(getPersonalPaygUiState({ ...base, ...overrides })).toEqual(expected) + ] as const + + it.each(testCases)('%s', (_name, overrides, expected) => { + expect( + getPersonalPaygUiState({ + ...base, + ...overrides, + labels, + }) + ).toEqual(expected) }) }) diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/payg-ui.ts b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/payg-ui.ts index ed4540320..7efe84f02 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/payg-ui.ts +++ b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/payg-ui.ts @@ -17,6 +17,14 @@ export type PersonalPaygUiState = { showUsageLimitControl: boolean } +export type PersonalPaygUiLabels = { + resolvePayment: string + addPaymentMethod: string + activatePayg: string + increaseLimit: string + manageBilling: string +} + const PAYMENT_RESOLUTION_STATUSES = new Set([ 'past_due', 'incomplete', @@ -32,6 +40,7 @@ export function getPersonalPaygUiState(params: { subscriptionStatus: string | null | undefined canEditUsageLimit: boolean tierCanEditUsageLimit: boolean + labels: PersonalPaygUiLabels }): PersonalPaygUiState { const showBadge = params.tierCanEditUsageLimit || params.hasStripeMonthlyPriceId const needsPaymentResolution = @@ -39,7 +48,7 @@ export function getPersonalPaygUiState(params: { if (needsPaymentResolution) { return { - badgeText: 'Resolve Payment', + badgeText: params.labels.resolvePayment, primaryAction: 'resolve_payment', showBadge, showUsageLimitControl: false, @@ -48,7 +57,7 @@ export function getPersonalPaygUiState(params: { if (!params.hasPaymentMethodOnFile) { return { - badgeText: 'Add Payment Method', + badgeText: params.labels.addPaymentMethod, primaryAction: 'add_payment_method', showBadge, showUsageLimitControl: false, @@ -57,7 +66,7 @@ export function getPersonalPaygUiState(params: { if (!params.hasStripeSubscription) { return { - badgeText: 'Activate PAYG', + badgeText: params.labels.activatePayg, primaryAction: 'activate_payg', showBadge, showUsageLimitControl: false, @@ -66,7 +75,7 @@ export function getPersonalPaygUiState(params: { if (params.canEditUsageLimit) { return { - badgeText: 'Increase Limit', + badgeText: params.labels.increaseLimit, primaryAction: 'increase_limit', showBadge, showUsageLimitControl: true, @@ -74,7 +83,7 @@ export function getPersonalPaygUiState(params: { } return { - badgeText: 'Manage Billing', + badgeText: params.labels.manageBilling, primaryAction: 'manage_billing', showBadge, showUsageLimitControl: false, diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/subscription.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/subscription.tsx index f25f0fed9..12059351d 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/subscription.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/subscription.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' +import { useLocale } from 'next-intl' import { Skeleton, Switch } from '@/components/ui' import { Button } from '@/components/ui/button' import { useSession } from '@/lib/auth-client' @@ -18,6 +19,8 @@ import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organi import { usePublicBillingCatalog } from '@/hooks/queries/public-billing-catalog' import { useSubscriptionData, useUsageLimitData } from '@/hooks/queries/subscription' import { useGeneralStore } from '@/stores/settings/general/store' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { UsageHeader } from '../shared/usage-header' import { PlanCard, UsageLimit, type UsageLimitRef, WorkspaceBillingOwnerEditor } from './components' import { @@ -152,6 +155,8 @@ function openContactUrl(url: string | null) { } export function Subscription({ onOpenChange }: SubscriptionProps) { + const locale = useLocale() as LocaleCode + const subscriptionCopy = getPublicCopy(locale).workspace.settingsModal.subscription const { data: session } = useSession() const { handleUpgrade } = useSubscriptionUpgrade() @@ -260,6 +265,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { subscriptionStatus: billingPayload?.status ?? null, canEditUsageLimit: canEditPersonalUsageLimit, tierCanEditUsageLimit: surfaceState.canEditUsageLimit, + labels: subscriptionCopy.badges, }) const normalizedBillingStatus = billingPayload?.billingBlocked ? 'blocked' @@ -292,8 +298,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { !isOrganizationPlan && personalPaygUiState.showBadge ? personalPaygUiState.badgeText : subscription.isFree - ? 'Upgrade' - : 'Increase Limit' + ? subscriptionCopy.titles.upgrade + : subscriptionCopy.titles.increaseLimit const hasUpgradePlans = surfaceState.visibleUpgradeTiers.length > 0 || surfaceState.showEnterprisePlaceholder const enterpriseContactUrl = @@ -311,16 +317,16 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { }) } catch (error) { setUpgradeError(targetTier.billingTierId) - alert(error instanceof Error ? error.message : 'Unknown error occurred') + alert(error instanceof Error ? error.message : subscriptionCopy.errors.unknown) } }, - [activeOrgId, handleUpgrade] + [activeOrgId, handleUpgrade, subscriptionCopy.errors.unknown] ) const openBillingPortal = useCallback( async (context: 'user' | 'organization') => { if (context === 'organization' && !activeOrgId) { - alert('Select an organization to manage billing.') + alert(subscriptionCopy.errors.selectOrganization) return } @@ -329,7 +335,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { organizationId: context === 'organization' ? activeOrgId : undefined, }) }, - [activeOrgId] + [activeOrgId, subscriptionCopy.errors.selectOrganization] ) const activatePayg = useCallback(async () => { @@ -349,14 +355,19 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { return } - throw new Error(result?.error || 'Failed to activate PAYG') + throw new Error(result?.error || subscriptionCopy.errors.activatePayg) } await Promise.all([refetchSubscription(), refetchUsageLimit()]) } finally { setIsPrimaryActionPending(false) } - }, [openBillingPortal, refetchSubscription, refetchUsageLimit]) + }, [ + openBillingPortal, + refetchSubscription, + refetchUsageLimit, + subscriptionCopy.errors.activatePayg, + ]) const handleBadgeClick = () => { if (isPrimaryActionPending) { @@ -369,12 +380,12 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { case 'add_payment_method': case 'manage_billing': void openBillingPortal('user').catch((error) => { - alert(error instanceof Error ? error.message : 'Failed to open billing portal') + alert(error instanceof Error ? error.message : subscriptionCopy.errors.openBillingPortal) }) return case 'activate_payg': void activatePayg().catch((error) => { - alert(error instanceof Error ? error.message : 'Failed to activate PAYG') + alert(error instanceof Error ? error.message : subscriptionCopy.errors.activatePayg) }) return case 'increase_limit': @@ -422,7 +433,9 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { onBadgeClick={handleBadgeClick} seatsText={ surfaceState.canManageOrganizationPlan || surfaceState.isCustomOrganizationPlan - ? `${organizationBillingPayload?.totalSeats || subscription.seats || 1} seats` + ? formatTemplate(subscriptionCopy.seatsText, { + count: organizationBillingPayload?.totalSeats || subscription.seats || 1, + }) : undefined } current={aggregatedCurrentUsage} @@ -440,7 +453,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { try { await openBillingPortal(isOrganizationPlan ? 'organization' : 'user') } catch (error) { - alert(error instanceof Error ? error.message : 'Failed to open billing portal') + alert(error instanceof Error ? error.message : subscriptionCopy.errors.openBillingPortal) } }} rightContent={ @@ -489,7 +502,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { {surfaceState.showTeamMemberView && ( <div className='text-center'> <p className='text-muted-foreground text-xs'> - Contact your team admin to increase limits + {subscriptionCopy.descriptions.teamMemberView} </p> </div> )} @@ -510,7 +523,13 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { price={formatBillingPriceLabel(tier)} priceSubtext={formatBillingPricePeriod(tier) ?? undefined} features={toPlanFeatures(tier.pricingFeatures)} - buttonText={subscription.isFree ? 'Upgrade' : `Upgrade to ${tier.displayName}`} + buttonText={ + subscription.isFree + ? subscriptionCopy.titles.upgrade + : formatTemplate(subscriptionCopy.actions.upgradeTo, { + name: tier.displayName, + }) + } onButtonClick={() => handleUpgradeWithErrorHandling(toUpgradeTarget(tier))} isError={upgradeError === tier.id} layout='vertical' @@ -522,14 +541,14 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { {surfaceState.showEnterprisePlaceholder && surfaceState.enterprisePlaceholder && ( <PlanCard name={surfaceState.enterprisePlaceholder.displayName} - price='Custom' + price={subscriptionCopy.titles.custom} priceSubtext={ surfaceState.visibleUpgradeTiers.length !== 1 ? surfaceState.enterprisePlaceholder.description : undefined } features={toPlanFeatures(surfaceState.enterprisePlaceholder.pricingFeatures)} - buttonText='Contact' + buttonText={subscriptionCopy.actions.contact} onButtonClick={() => openContactUrl(enterpriseContactUrl)} layout={surfaceState.visibleUpgradeTiers.length === 1 ? 'vertical' : 'horizontal'} /> @@ -540,7 +559,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { {(subscription.isPaid || showPersonalSubscriptionManagement) && billingPayload?.periodEnd && ( <div className='mt-4 flex items-center justify-between'> - <span className='font-medium text-sm'>Next Billing Date</span> + <span className='font-medium text-sm'>{subscriptionCopy.titles.nextBillingDate}</span> <span className='text-muted-foreground text-sm'> {new Date(billingPayload.periodEnd).toLocaleDateString()} </span> @@ -556,7 +575,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { {surfaceState.isCustomOrganizationPlan && ( <div className='text-center'> <p className='text-muted-foreground text-xs'> - Contact your account team for billing tier and usage limit changes + {subscriptionCopy.descriptions.customPlan} </p> </div> )} @@ -567,11 +586,11 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { <div> <span className='font-medium text-sm'> {billingPayload?.cancelAtPeriodEnd - ? 'Restore Subscription' - : 'Manage Subscription'} + ? subscriptionCopy.titles.restore + : subscriptionCopy.titles.manage} </span> <p className='mt-1 text-muted-foreground text-xs'> - Open Stripe Billing Portal to cancel, restore, or update your subscription. + {subscriptionCopy.descriptions.manage} </p> </div> <Button @@ -581,13 +600,15 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { void openBillingPortal(isOrganizationPlan ? 'organization' : 'user').catch( (error) => { alert( - error instanceof Error ? error.message : 'Failed to open billing portal' + error instanceof Error + ? error.message + : subscriptionCopy.errors.openBillingPortal ) } ) }} > - Manage + {subscriptionCopy.actions.manage} </Button> </div> </div> @@ -598,6 +619,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { } function BillingUsageNotificationsToggle() { + const locale = useLocale() as LocaleCode + const subscriptionCopy = getPublicCopy(locale).workspace.settingsModal.subscription const enabled = useGeneralStore((s) => s.isBillingUsageNotificationsEnabled) const updateSetting = useUpdateGeneralSetting() const isLoading = updateSetting.isPending @@ -605,9 +628,9 @@ function BillingUsageNotificationsToggle() { return ( <div className='mt-4 flex items-center justify-between'> <div className='flex flex-col'> - <span className='font-medium text-sm'>Usage notifications</span> + <span className='font-medium text-sm'>{subscriptionCopy.titles.usageNotifications}</span> <span className='text-muted-foreground text-xs'> - Email me when usage reaches the billing warning threshold + {subscriptionCopy.descriptions.usageNotifications} </span> </div> <Switch diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx index 078f04931..539f9db9a 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx @@ -1,5 +1,8 @@ +'use client' + import React, { useMemo, useState } from 'react' import { CheckCircle } from 'lucide-react' +import { useLocale } from 'next-intl' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -8,6 +11,8 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { quickValidateEmail } from '@/lib/email/validation' import { cn } from '@/lib/utils' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' type PermissionType = 'read' | 'write' | 'admin' @@ -20,13 +25,27 @@ interface PermissionSelectorProps { const PermissionSelector = React.memo<PermissionSelectorProps>( ({ value, onChange, disabled = false, className = '' }) => { + const locale = useLocale() as LocaleCode + const permissionCopy = getPublicCopy(locale).workspace.settingsModal.team.invitation.permissions const permissionOptions = useMemo( () => [ - { value: 'read' as PermissionType, label: 'Read', description: 'View only' }, - { value: 'write' as PermissionType, label: 'Write', description: 'Edit content' }, - { value: 'admin' as PermissionType, label: 'Admin', description: 'Full access' }, + { + value: 'read' as PermissionType, + label: permissionCopy.read.label, + description: permissionCopy.read.description, + }, + { + value: 'write' as PermissionType, + label: permissionCopy.write.label, + description: permissionCopy.write.description, + }, + { + value: 'admin' as PermissionType, + label: permissionCopy.admin.label, + description: permissionCopy.admin.description, + }, ], - [] + [permissionCopy] ) return ( @@ -99,6 +118,8 @@ export function MemberInvitationCard({ seatLimited, availableSeats = 0, }: MemberInvitationCardProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.settingsModal.team.invitation const selectedCount = selectedWorkspaces.length const hasAvailableSeats = seatLimited ? availableSeats > 0 : true const inviteEnabled = canInviteMembers && hasAvailableSeats @@ -113,7 +134,7 @@ export function MemberInvitationCard({ const validation = quickValidateEmail(email.trim()) if (!validation.isValid) { - setEmailError(validation.reason || 'Please enter a valid email address') + setEmailError(copy.invalidEmail) } else { setEmailError('') } @@ -146,9 +167,9 @@ export function MemberInvitationCard({ <div className='space-y-4'> {/* Header - clean like account page */} <div> - <h4 className='font-medium text-sm'>Invite Team Members</h4> + <h4 className='font-medium text-sm'>{copy.title}</h4> <p className='text-muted-foreground text-xs'> - Add new members to your team and optionally give them access to specific workspaces + {copy.description} </p> </div> @@ -157,7 +178,7 @@ export function MemberInvitationCard({ <div className='flex-1'> <div> <Input - placeholder='Enter email address' + placeholder={copy.emailPlaceholder} value={inviteEmail} onChange={handleEmailChange} disabled={isInviting || !inviteEnabled} @@ -180,7 +201,7 @@ export function MemberInvitationCard({ disabled={isInviting || !inviteEnabled} className='h-9 shrink-0 rounded-sm text-sm' > - {showWorkspaceInvite ? 'Hide' : 'Add'} Workspaces + {showWorkspaceInvite ? copy.hideWorkspaces : copy.addWorkspaces} </Button> <Button size='sm' @@ -189,7 +210,7 @@ export function MemberInvitationCard({ className='h-9 shrink-0 rounded-sm' > {isInviting ? <ButtonSkeleton /> : null} - {canInviteMembers ? (hasAvailableSeats ? 'Invite' : 'No Seats') : 'Unavailable'} + {canInviteMembers ? (hasAvailableSeats ? copy.invite : copy.noSeats) : copy.unavailable} </Button> </div> @@ -203,24 +224,29 @@ export function MemberInvitationCard({ <div className='space-y-4'> <div className='flex items-center justify-between'> <div className='flex items-center gap-2'> - <h5 className='font-medium text-xs'>Workspace Access</h5> + <h5 className='font-medium text-xs'>{copy.workspaceAccess}</h5> <Badge variant='outline' className='h-[1.125rem] rounded-md px-2 py-0 text-xs'> - Optional + {copy.optional} </Badge> </div> {selectedCount > 0 && ( - <span className='text-muted-foreground text-xs'>{selectedCount} selected</span> + <span className='text-muted-foreground text-xs'> + {formatTemplate(copy.selected, { + count: selectedCount, + plural: selectedCount !== 1 ? 's' : '', + })} + </span> )} </div> <p className='text-muted-foreground text-xs leading-relaxed'> - Grant access to specific workspaces. You can modify permissions later. + {copy.grantAccess} </p> {userWorkspaces.length === 0 ? ( <div className='rounded-md border border-dashed py-8 text-center'> - <p className='text-muted-foreground text-sm'>No workspaces available</p> + <p className='text-muted-foreground text-sm'>{copy.noWorkspacesAvailable}</p> <p className='mt-1 text-muted-foreground text-xs'> - You need admin access to workspaces to invite members + {copy.needAdminAccess} </p> </div> ) : ( @@ -258,7 +284,7 @@ export function MemberInvitationCard({ variant='outline' className='h-[1.125rem] rounded-md px-2 py-0 text-xs' > - Owner + {copy.owner} </Badge> )} </div> @@ -293,9 +319,12 @@ export function MemberInvitationCard({ <Alert className='rounded-sm border-green-200 bg-green-50 text-green-800 dark:border-green-800/50 dark:bg-green-950/20 dark:text-green-300'> <CheckCircle className='h-4 w-4 text-green-600 dark:text-green-400' /> <AlertDescription> - Invitation sent successfully - {selectedCount > 0 && - ` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`} + {selectedCount > 0 + ? formatTemplate(copy.sentSuccessWithAccess, { + count: selectedCount, + plural: selectedCount !== 1 ? 's' : '', + }) + : copy.sentSuccess} </AlertDescription> </Alert> )} diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx index b4109f898..8057ef074 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -1,8 +1,11 @@ +import { useLocale } from 'next-intl' import { RefreshCw } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' interface NoOrganizationViewProps { canCreateOrganization: boolean @@ -25,31 +28,34 @@ export function NoOrganizationView({ isCreatingOrg, error, }: NoOrganizationViewProps) { + const locale = useLocale() as LocaleCode + const teamCopy = getPublicCopy(locale).workspace.settingsModal.team + if (!canCreateOrganization) { return ( <div className='px-6 pt-4 pb-4'> <div className='flex flex-col gap-6'> <div> - <h4 className='font-medium text-sm'>Upgrade To Create a Team</h4> + <h4 className='font-medium text-sm'>{teamCopy.upgradeToCreateTeam}</h4> <p className='mt-1 text-muted-foreground text-xs'> - Upgrade to an organization tier to create a team workspace. + {teamCopy.upgradeToCreateTeamDescription} </p> </div> <div className='flex justify-end'> <Button - onClick={() => { - window.dispatchEvent( - new CustomEvent('open-settings', { detail: { tab: 'subscription' } }) - ) - }} - className='h-9 rounded-sm' - > - Open Subscription Settings - </Button> - </div> + onClick={() => { + window.dispatchEvent( + new CustomEvent('open-settings', { detail: { tab: 'subscription' } }) + ) + }} + className='h-9 rounded-sm' + > + {teamCopy.openSubscriptionSettings} + </Button> </div> </div> + </div> ) } @@ -57,29 +63,29 @@ export function NoOrganizationView({ <div className='px-6 pt-4 pb-4'> <div className='flex flex-col gap-6'> <div> - <h4 className='font-medium text-sm'>Create Your Team Workspace</h4> + <h4 className='font-medium text-sm'>{teamCopy.createYourTeamWorkspace}</h4> <p className='mt-1 text-muted-foreground text-xs'> - Create an organization to collaborate with your team. + {teamCopy.createYourTeamWorkspaceDescription} </p> </div> <div className='space-y-4'> <div> <Label htmlFor='orgName' className='font-medium text-sm'> - Team Name + {teamCopy.teamName} </Label> <Input id='orgName' value={orgName} onChange={onOrgNameChange} - placeholder='My Team' + placeholder={teamCopy.teamNamePlaceholder} className='mt-1' /> </div> <div> <Label htmlFor='orgSlug' className='font-medium text-sm'> - Team URL + {teamCopy.teamUrl} </Label> <div className='mt-1 flex items-center'> <div className='rounded-l-[8px] border border-r-0 bg-muted px-3 py-2 text-muted-foreground text-sm'> @@ -89,7 +95,7 @@ export function NoOrganizationView({ id='orgSlug' value={orgSlug} onChange={(e) => setOrgSlug(e.target.value)} - placeholder='my-team' + placeholder={teamCopy.teamSlugPlaceholder} className='rounded-l-none' /> </div> @@ -97,7 +103,7 @@ export function NoOrganizationView({ {error ? ( <Alert variant='destructive' className='rounded-sm'> - <AlertTitle>Error</AlertTitle> + <AlertTitle>{teamCopy.error}</AlertTitle> <AlertDescription>{error}</AlertDescription> </Alert> ) : null} @@ -109,7 +115,7 @@ export function NoOrganizationView({ className='h-9 rounded-sm' > {isCreatingOrg ? <RefreshCw className='mr-2 h-4 w-4 animate-spin' /> : null} - Create Team Workspace + {teamCopy.createTeamWorkspace} </Button> </div> </div> diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx index 41df44b4d..26f45c40c 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx @@ -1,3 +1,4 @@ +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Dialog, @@ -7,6 +8,8 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' interface RemoveMemberDialogProps { open: boolean @@ -31,16 +34,23 @@ export function RemoveMemberDialog({ onCancel, isSelfRemoval = false, }: RemoveMemberDialogProps) { + const locale = useLocale() as LocaleCode + const teamCopy = getPublicCopy(locale).workspace.settingsModal.team + return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent> <DialogHeader> - <DialogTitle>{isSelfRemoval ? 'Leave Organization' : 'Remove Team Member'}</DialogTitle> + <DialogTitle> + {isSelfRemoval ? teamCopy.leaveOrganization : teamCopy.removeTeamMember} + </DialogTitle> <DialogDescription> {isSelfRemoval - ? 'Are you sure you want to leave this organization? You will lose access to all team resources.' - : `Are you sure you want to remove ${memberName} from the team?`}{' '} - <span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span> + ? teamCopy.leaveOrganizationDescription + : teamCopy.removeMemberDescription.replaceAll('{{name}}', memberName)}{' '} + <span className='text-red-500 dark:text-red-500'> + {teamCopy.thisActionCannotBeUndone} + </span> </DialogDescription> </DialogHeader> @@ -55,25 +65,25 @@ export function RemoveMemberDialog({ onChange={(e) => onShouldReduceSeatsChange(e.target.checked)} /> <label htmlFor='reduce-seats' className='text-xs'> - Also reduce seat count in my subscription + {teamCopy.alsoReduceSeatCount} </label> </div> <p className='mt-1 text-muted-foreground text-xs'> - If selected, your team seat count will be reduced by 1, lowering your monthly billing. + {teamCopy.reduceSeatCountDescription} </p> </div> )} <DialogFooter> <Button variant='outline' onClick={onCancel} className='h-9 rounded-sm'> - Cancel + {teamCopy.cancel} </Button> <Button variant='destructive' onClick={() => onConfirmRemove(shouldReduceSeats)} className='h-9 rounded-sm bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600' > - {isSelfRemoval ? 'Leave Organization' : 'Remove'} + {isSelfRemoval ? teamCopy.leaveOrganization : teamCopy.remove} </Button> </DialogFooter> </DialogContent> diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-members/team-members.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-members/team-members.tsx index af6f4a06b..95c78cff1 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-members/team-members.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-members/team-members.tsx @@ -1,9 +1,14 @@ +'use client' + import { useEffect, useState } from 'react' import { LogOut, UserX, X } from 'lucide-react' +import { useLocale } from 'next-intl' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console/logger' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import type { Invitation, Member, Organization } from '@/stores/organization' const logger = createLogger('TeamMembers') @@ -47,6 +52,9 @@ export function TeamMembers({ onRemoveMember, onCancelInvitation, }: TeamMembersProps) { + const locale = useLocale() as LocaleCode + const teamCopy = getPublicCopy(locale).workspace.settingsModal.team.members + const roleCopy = getPublicCopy(locale).workspace.switcher.roles const [memberUsageData, setMemberUsageData] = useState<Record<string, number>>({}) const [memberUsageMode, setMemberUsageMode] = useState<MemberUsageMode>('individual') const [sharedUsageTotal, setSharedUsageTotal] = useState<number | null>(null) @@ -111,8 +119,8 @@ export function TeamMembers({ organization.members.forEach((member: Member) => { const userId = member.user?.id const usageAmount = userId ? (memberUsageData[userId] ?? 0) : 0 - const name = member.user?.name || 'Unknown' - const usage = memberUsageMode === 'pooled' ? 'Shared pool' : `$${usageAmount.toFixed(2)}` + const name = member.user?.name || teamCopy.unknown + const usage = memberUsageMode === 'pooled' ? teamCopy.sharedPool : `$${usageAmount.toFixed(2)}` const memberItem: MemberItem = { type: 'member', @@ -154,7 +162,7 @@ export function TeamMembers({ } if (teamItems.length === 0) { - return <div className='text-center text-muted-foreground text-sm'>No team members yet.</div> + return <div className='text-center text-muted-foreground text-sm'>{teamCopy.empty}</div> } // Check if current user can leave (is a member but not owner) @@ -180,10 +188,10 @@ export function TeamMembers({ <div className='flex flex-col gap-4'> {/* Header - simple like account page */} <div> - <h4 className='font-medium text-sm'>Team Members</h4> + <h4 className='font-medium text-sm'>{teamCopy.title}</h4> {isAdminOrOwner && memberUsageMode === 'pooled' && sharedUsageTotal !== null && ( <p className='mt-1 text-muted-foreground text-xs'> - Shared billed usage: ${sharedUsageTotal.toFixed(2)} + {formatTemplate(teamCopy.sharedUsage, { amount: sharedUsageTotal.toFixed(2) })} </p> )} </div> @@ -226,12 +234,12 @@ export function TeamMembers({ : 'bg-[var(--primary)]/10 text-muted-foreground' } `} > - {item.role.charAt(0).toUpperCase() + item.role.slice(1)} + {roleCopy[item.role as keyof typeof roleCopy] ?? item.role} </span> )} {item.type === 'invitation' && ( <span className='inline-flex h-[1.125rem] items-center rounded-md bg-muted px-2 py-0 font-medium text-muted-foreground text-xs'> - Pending + {teamCopy.pending} </span> )} </div> @@ -241,10 +249,10 @@ export function TeamMembers({ {/* Usage stats - matching subscription layout */} {isAdminOrOwner && ( <div className='hidden items-center text-xs tabular-nums sm:flex'> - <div className='text-center'> - <div className='text-muted-foreground'> - {memberUsageMode === 'pooled' ? 'Billing' : 'Usage'} - </div> + <div className='text-center'> + <div className='text-muted-foreground'> + {memberUsageMode === 'pooled' ? teamCopy.billing : teamCopy.usage} + </div> <div className='font-medium'> {isLoadingUsage && item.type === 'member' ? ( <span className='inline-block h-3 w-12 animate-pulse rounded bg-muted' /> @@ -275,7 +283,7 @@ export function TeamMembers({ <UserX className='h-4 w-4' /> </Button> </TooltipTrigger> - <TooltipContent side='left'>Remove Member</TooltipContent> + <TooltipContent side='left'>{teamCopy.removeMemberTooltip}</TooltipContent> </Tooltip> )} @@ -299,8 +307,8 @@ export function TeamMembers({ </TooltipTrigger> <TooltipContent side='left'> {cancellingInvitations.has(item.invitation.id) - ? 'Cancelling...' - : 'Cancel Invitation'} + ? teamCopy.cancelling + : teamCopy.cancelInvitationTooltip} </TooltipContent> </Tooltip> )} @@ -325,7 +333,7 @@ export function TeamMembers({ className='w-full hover:bg-card' > <LogOut className='mr-2 h-4 w-4' /> - Leave Organization + {teamCopy.leaveOrganization} </Button> </div> )} diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx index 20ca8fd57..e287be910 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx @@ -1,7 +1,10 @@ +import { useLocale } from 'next-intl' import { Building2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' +import { getPublicCopy, formatTemplate } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' type Subscription = { id: string @@ -64,6 +67,8 @@ export function TeamSeatsOverview({ onReduceSeats, onAddSeatDialog, }: TeamSeatsOverviewProps) { + const locale = useLocale() as LocaleCode + const teamCopy = getPublicCopy(locale).workspace.settingsModal.team const canManageSeats = subscriptionData?.tier?.ownerType === 'organization' && subscriptionData?.tier?.seatMode === 'adjustable' @@ -81,9 +86,9 @@ export function TeamSeatsOverview({ <Building2 className='h-6 w-6 text-yellow-600' /> </div> <div className='space-y-2'> - <p className='font-medium text-sm'>No Team Subscription Found</p> + <p className='font-medium text-sm'>{teamCopy.noTeamSubscriptionFound}</p> <p className='text-muted-foreground text-sm'> - Your subscription may need to be transferred to this organization. + {teamCopy.subscriptionMayNeedTransfer} </p> </div> <Button @@ -93,7 +98,7 @@ export function TeamSeatsOverview({ disabled={isLoading} className='h-9 rounded-sm' > - Set Up Team Subscription + {teamCopy.setUpTeamSubscription} </Button> </div> </div> @@ -109,13 +114,19 @@ export function TeamSeatsOverview({ <div className='space-y-2'> <div className='flex items-center justify-between'> <div className='flex items-center gap-2'> - <span className='font-medium text-sm'>Seats</span> - <span className='text-muted-foreground text-xs'>(${pricePerSeat}/month each)</span> + <span className='font-medium text-sm'>{teamCopy.seats}</span> + <span className='text-muted-foreground text-xs'> + {formatTemplate(teamCopy.pricePerSeat, { price: pricePerSeat })} + </span> </div> <div className='flex items-center gap-1 text-xs tabular-nums'> - <span className='text-muted-foreground'>{usedSeats} used</span> + <span className='text-muted-foreground'> + {formatTemplate(teamCopy.used, { count: usedSeats })} + </span> <span className='text-muted-foreground'>/</span> - <span className='text-muted-foreground'>{subscriptionData.seats || 0} total</span> + <span className='text-muted-foreground'> + {formatTemplate(teamCopy.total, { count: subscriptionData.seats || 0 })} + </span> </div> </div> @@ -129,7 +140,7 @@ export function TeamSeatsOverview({ disabled={(subscriptionData.seats || 0) <= 1 || isLoading} className='h-8 flex-1 rounded-sm' > - Remove Seat + {teamCopy.removeSeat} </Button> <Button size='sm' @@ -137,7 +148,7 @@ export function TeamSeatsOverview({ disabled={isLoading} className='h-8 flex-1 rounded-sm' > - Add Seat + {teamCopy.addSeat} </Button> </div> </div> diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-seats/team-seats.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-seats/team-seats.tsx index c890ba3eb..41f41d161 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-seats/team-seats.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-seats/team-seats.tsx @@ -1,3 +1,4 @@ +import { useLocale } from 'next-intl' import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { @@ -11,6 +12,8 @@ import { import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' interface TeamSeatsProps { open: boolean @@ -45,6 +48,8 @@ export function TeamSeats({ showCostBreakdown = false, isCancelledAtPeriodEnd = false, }: TeamSeatsProps) { + const locale = useLocale() as LocaleCode + const teamCopy = getPublicCopy(locale).workspace.settingsModal.team const [selectedSeats, setSelectedSeats] = useState(initialSeats) useEffect(() => { @@ -68,7 +73,7 @@ export function TeamSeats({ </DialogHeader> <div className='py-4'> - <Label htmlFor='seats'>Number of seats</Label> + <Label htmlFor='seats'>{teamCopy.numberOfSeats}</Label> <Input id='seats' type='number' @@ -92,27 +97,33 @@ export function TeamSeats({ /> <p className='mt-2 text-muted-foreground text-sm'> - Your team will have {selectedSeats} {selectedSeats === 1 ? 'seat' : 'seats'} with a - total of ${totalMonthlyCost} inference credits per month. + {formatTemplate(teamCopy.yourTeamWillHave, { + count: selectedSeats, + seatWord: selectedSeats === 1 ? teamCopy.seat : teamCopy.seats, + cost: totalMonthlyCost, + })} </p> <p className='mt-1 text-muted-foreground text-xs'> {maximumSeats === null - ? `Minimum ${minimumSeats} seats. No maximum seat cap applies to this tier.` - : `Choose between ${minimumSeats} and ${maximumSeats} seats for this tier.`} + ? formatTemplate(teamCopy.minimumSeatsNoMax, { minimum: minimumSeats }) + : formatTemplate(teamCopy.chooseBetweenSeats, { + minimum: minimumSeats, + maximum: maximumSeats, + })} </p> {showCostBreakdown && currentSeats !== undefined && ( <div className='mt-3 rounded-md bg-muted/50 p-3'> <div className='flex justify-between text-sm'> - <span>Current seats:</span> + <span>{teamCopy.currentSeats}</span> <span>{currentSeats}</span> </div> <div className='flex justify-between text-sm'> - <span>New seats:</span> + <span>{teamCopy.newSeats}</span> <span>{selectedSeats}</span> </div> <div className='mt-2 flex justify-between border-t pt-2 font-medium text-sm'> - <span>Monthly cost change:</span> + <span>{teamCopy.monthlyCostChange}</span> <span> {costChange > 0 ? '+' : ''}${costChange} </span> @@ -123,7 +134,7 @@ export function TeamSeats({ <DialogFooter> <Button variant='outline' onClick={() => onOpenChange(false)} disabled={isLoading}> - Cancel + {teamCopy.cancel} </Button> <TooltipProvider> <Tooltip> @@ -140,7 +151,7 @@ export function TeamSeats({ {isLoading ? ( <div className='flex items-center space-x-2'> <div className='h-4 w-4 animate-spin rounded-full border-2 border-current border-b-transparent' /> - <span>Loading...</span> + <span>{teamCopy.loading}</span> </div> ) : ( <span>{confirmButtonText}</span> @@ -150,10 +161,7 @@ export function TeamSeats({ </TooltipTrigger> {isCancelledAtPeriodEnd && ( <TooltipContent> - <p> - To update seats, go to Subscription {'>'} Manage {'>'} Keep Subscription to - reactivate - </p> + <p>{teamCopy.reactivateSubscription}</p> </TooltipContent> )} </Tooltip> diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-usage/team-usage.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-usage/team-usage.tsx index 8092350a6..532003d46 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-usage/team-usage.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/team-usage/team-usage.tsx @@ -1,4 +1,5 @@ import { useRef } from 'react' +import { useLocale } from 'next-intl' import { Skeleton } from '@/components/ui/skeleton' import { useActiveOrganization } from '@/lib/auth-client' import { openBillingPortal } from '@/lib/billing/billing-portal' @@ -9,12 +10,16 @@ import { type UsageLimitRef, } from '@/global-navbar/settings-modal/components/subscription/components' import { useOrganizationBilling } from '@/hooks/queries/organization' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' interface TeamUsageProps { hasAdminAccess: boolean } export function TeamUsage({ hasAdminAccess }: TeamUsageProps) { + const locale = useLocale() as LocaleCode + const subscriptionCopy = getPublicCopy(locale).workspace.settingsModal.subscription const { data: activeOrg } = useActiveOrganization() const { data: billingData, @@ -51,7 +56,7 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) { return ( <div className='rounded-sm border bg-background p-3 shadow-xs'> <p className='text-center text-red-500 text-xs'> - {error instanceof Error ? error.message : 'Failed to load billing data'} + {error instanceof Error ? error.message : subscriptionCopy.errors.loadBillingData} </p> </div> ) @@ -75,19 +80,21 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) { const status: 'ok' | 'warning' | 'exceeded' = percentUsed >= 100 ? 'exceeded' : percentUsed >= warningThresholdPercent ? 'warning' : 'ok' - const title = organizationBillingPayload.subscriptionTier?.displayName || 'Organization Usage' + const title = + organizationBillingPayload.subscriptionTier?.displayName || + subscriptionCopy.titles.organizationUsage const canEditUsageLimit = canTierEditUsageLimit(organizationBillingPayload.subscriptionTier) return ( - <UsageHeader + <UsageHeader title={title} gradientTitle showBadge={!!(hasAdminAccess && activeOrg?.id && canEditUsageLimit)} - badgeText={canEditUsageLimit ? 'Increase Limit' : undefined} + badgeText={canEditUsageLimit ? subscriptionCopy.titles.increaseLimit : undefined} onBadgeClick={() => { if (canEditUsageLimit) usageLimitRef.current?.startEdit() }} - seatsText={`${seatsCount} seats`} + seatsText={formatTemplate(subscriptionCopy.seatsText, { count: seatsCount })} current={currentUsage} limit={currentCap} isBlocked={Boolean(organizationBillingPayload?.billingBlocked)} @@ -95,7 +102,7 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) { percentUsed={percentUsed} onResolvePayment={async () => { if (!activeOrg?.id) { - alert('Select an organization to manage billing.') + alert(subscriptionCopy.errors.selectOrganization) return } @@ -105,7 +112,7 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) { organizationId: activeOrg.id, }) } catch (e) { - alert(e instanceof Error ? e.message : 'Failed to open billing portal') + alert(e instanceof Error ? e.message : subscriptionCopy.errors.openBillingPortal) } }} rightContent={ diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/workspace-billing/workspace-billing.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/workspace-billing/workspace-billing.tsx index 4bfc21017..c8982e5c9 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/workspace-billing/workspace-billing.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/components/workspace-billing/workspace-billing.tsx @@ -1,8 +1,13 @@ +import { useLocale } from 'next-intl' import { Building2, UserRound } from 'lucide-react' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import type { OrganizationWorkspaceRecord } from '@/hooks/queries/organization' +type WorkspaceBillingCopy = ReturnType<typeof getPublicCopy>['workspace']['settingsModal']['team']['billing'] + interface WorkspaceBillingProps { billedWorkspaces: OrganizationWorkspaceRecord[] availableWorkspaces: OrganizationWorkspaceRecord[] @@ -31,6 +36,7 @@ function WorkspaceBillingSkeleton() { function WorkspaceRow(props: { workspace: OrganizationWorkspaceRecord + copy: WorkspaceBillingCopy actionLabel: string actionDisabled?: boolean actionVariant?: 'default' | 'outline' @@ -38,6 +44,7 @@ function WorkspaceRow(props: { }) { const { workspace, + copy, actionLabel, actionDisabled = false, actionVariant = 'default', @@ -52,17 +59,17 @@ function WorkspaceRow(props: { {workspace.billingOwner.type === 'organization' ? ( <span className='inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[11px] text-blue-700'> <Building2 className='h-3 w-3' /> - Organization + {copy.organization} </span> ) : ( <span className='inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground'> <UserRound className='h-3 w-3' /> - Owner billing + {copy.ownerBilling} </span> )} </div> <p className='truncate text-muted-foreground text-xs'> - Owner: {workspace.ownerName || workspace.ownerId} + {copy.ownerLabel} {workspace.ownerName || workspace.ownerId} </p> </div> <Button @@ -91,6 +98,9 @@ export function WorkspaceBilling({ onAssignWorkspace, onReleaseWorkspace, }: WorkspaceBillingProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.settingsModal.team.billing + if (!canManage) { return null } @@ -102,15 +112,13 @@ export function WorkspaceBilling({ return ( <div className='space-y-4 rounded-sm border bg-background p-4 shadow-xs'> <div className='space-y-1'> - <h4 className='font-medium text-sm'>Workspace Billing</h4> - <p className='text-muted-foreground text-xs'> - Choose which workspaces are billed to this organization and which stay with their owner. - </p> + <h4 className='font-medium text-sm'>{copy.title}</h4> + <p className='text-muted-foreground text-xs'>{copy.description}</p> </div> {!hasOrganizationBilling ? ( <div className='rounded-sm border border-dashed bg-muted/30 p-3 text-muted-foreground text-xs'> - Set up an organization billing tier before moving workspaces onto organization billing. + {copy.organizationBillingRequired} </div> ) : null} @@ -118,12 +126,12 @@ export function WorkspaceBilling({ <div className='space-y-2'> <div className='flex items-center justify-between'> - <h5 className='font-medium text-sm'>Organization-billed workspaces</h5> + <h5 className='font-medium text-sm'>{copy.organizationBilledTitle}</h5> <span className='text-muted-foreground text-xs'>{billedWorkspaces.length}</span> </div> {billedWorkspaces.length === 0 ? ( <div className='rounded-sm border border-dashed bg-muted/20 p-3 text-muted-foreground text-xs'> - No workspaces are currently billed to this organization. + {copy.organizationBilledEmpty} </div> ) : ( <div className='space-y-2'> @@ -131,7 +139,8 @@ export function WorkspaceBilling({ <WorkspaceRow key={workspace.id} workspace={workspace} - actionLabel='Return To Owner' + copy={copy} + actionLabel={copy.returnToOwner} actionVariant='outline' actionDisabled={isReleasing} onAction={onReleaseWorkspace} @@ -143,12 +152,12 @@ export function WorkspaceBilling({ <div className='space-y-2'> <div className='flex items-center justify-between'> - <h5 className='font-medium text-sm'>Available owner-billed workspaces</h5> + <h5 className='font-medium text-sm'>{copy.availableOwnerBilledTitle}</h5> <span className='text-muted-foreground text-xs'>{availableWorkspaces.length}</span> </div> {availableWorkspaces.length === 0 ? ( <div className='rounded-sm border border-dashed bg-muted/20 p-3 text-muted-foreground text-xs'> - No personal-billed admin workspaces are available to attach. + {copy.availableOwnerBilledEmpty} </div> ) : ( <div className='space-y-2'> @@ -156,7 +165,8 @@ export function WorkspaceBilling({ <WorkspaceRow key={workspace.id} workspace={workspace} - actionLabel='Bill To Organization' + copy={copy} + actionLabel={copy.billToOrganization} actionDisabled={!hasOrganizationBilling || isAssigning} onAction={onAssignWorkspace} /> diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/team-management.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/team-management.tsx index 6bf69fd73..5856a850e 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/team-management/team-management.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/team-management/team-management.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { useLocale } from 'next-intl' import { Skeleton } from '@/components/ui' import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' @@ -22,6 +23,8 @@ import { import { usePublicBillingCatalog } from '@/hooks/queries/public-billing-catalog' import { useSubscriptionData } from '@/hooks/queries/subscription' import { useAdminWorkspaces } from '@/hooks/queries/workspace' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { MemberInvitationCard, NoOrganizationView, @@ -77,6 +80,8 @@ type TeamSubscriptionData = { } export function TeamManagement() { + const locale = useLocale() as LocaleCode + const teamCopy = getPublicCopy(locale).workspace.settingsModal.team const { data: session } = useSession() const { handleUpgrade } = useSubscriptionUpgrade() @@ -240,12 +245,12 @@ export function TeamManagement() { const seatLimited = canInviteMembers const inviteUnavailableMessage = displayOrganization && !canInviteMembers - ? 'An active organization subscription is required before you can invite team members.' + ? teamCopy.inviteUnavailableMessage : null useEffect(() => { if (session?.user?.name && !orgName) { - const defaultName = `${session.user.name}'s Team` + const defaultName = formatTemplate(teamCopy.defaultTeamName, { name: session.user.name }) setOrgName(defaultName) setOrgSlug(generateSlug(defaultName)) } @@ -343,8 +348,8 @@ export function TeamManagement() { const isLeavingSelf = member.user?.email === session.user.email const displayName = isLeavingSelf - ? 'yourself' - : member.user?.name || member.user?.email || 'this member' + ? teamCopy.yourself + : member.user?.name || member.user?.email || teamCopy.thisMember setRemoveMemberDialog({ open: true, @@ -491,9 +496,9 @@ export function TeamManagement() { const confirmTeamUpgrade = useCallback( async (seats: number) => { - if (!session?.user || !adjustableSeatTier) { - alert('No public adjustable organization tier is configured') - return + if (!session?.user || !adjustableSeatTier) { + alert(teamCopy.noPublicAdjustableTier) + return } logger.info('Organization tier upgrade requested', { @@ -567,18 +572,18 @@ export function TeamManagement() { {currentTier?.ownerType === 'organization' && ( <div className='rounded-sm border bg-blue-50/50 p-4 shadow-xs dark:bg-blue-950/20'> <div className='space-y-3'> - <h4 className='font-medium text-sm'>How this team billing works</h4> + <h4 className='font-medium text-sm'>{teamCopy.howBillingWorks}</h4> <ul className='ml-4 list-disc space-y-2 text-muted-foreground text-xs'> <li> - Your team is billed a minimum of ${(subscriptionData?.seats || 0) * seatPriceUsd} - /month for {subscriptionData?.seats || 0} licensed seats - </li> - <li>Usage is tracked against the active included allowance for this tier</li> - <li>You can increase the usage limit to allow for higher usage</li> - <li> - Any usage beyond the minimum seat cost is billed as overage at the end of the - billing period + {formatTemplate(teamCopy.billingHowWorksSeatCost, { + amount: (subscriptionData?.seats || 0) * seatPriceUsd, + seats: subscriptionData?.seats || 0, + plural: (subscriptionData?.seats || 0) !== 1 ? 's' : '', + })} </li> + <li>{teamCopy.billingHowWorksUsageTracked}</li> + <li>{teamCopy.billingHowWorksIncreaseLimit}</li> + <li>{teamCopy.billingHowWorksOverage}</li> </ul> </div> </div> @@ -632,8 +637,7 @@ export function TeamManagement() { {adminOrOwner && ( <div className='mt-4 rounded-lg bg-muted/50 p-3'> <p className='text-muted-foreground text-xs'> - <span className='font-medium'>Note:</span> Users can only be part of one organization - at a time. They must leave their current organization before joining another. + <span className='font-medium'>{teamCopy.usageNote}</span> {teamCopy.usageNoteBody} </p> </div> )} @@ -666,15 +670,15 @@ export function TeamManagement() { <div className='mt-6 flex-shrink-0 border-t pt-6'> <div className='space-y-3 text-xs'> <div className='flex justify-between'> - <span className='text-muted-foreground'>Team ID:</span> + <span className='text-muted-foreground'>{teamCopy.teamId}</span> <span className='font-mono'>{displayOrganization.id}</span> </div> <div className='flex justify-between'> - <span className='text-muted-foreground'>Created:</span> + <span className='text-muted-foreground'>{teamCopy.created}</span> <span>{new Date(displayOrganization.createdAt).toLocaleDateString()}</span> </div> <div className='flex justify-between'> - <span className='text-muted-foreground'>Your Role:</span> + <span className='text-muted-foreground'>{teamCopy.yourRole}</span> <span className='font-medium capitalize'>{userRole}</span> </div> </div> @@ -715,8 +719,8 @@ export function TeamManagement() { <TeamSeats open={isAddSeatDialogOpen && isAdjustableSeatTier} onOpenChange={setIsAddSeatDialogOpen} - title='Add Team Seats' - description={`Each seat costs $${seatPriceUsd}/month and provides $${seatPriceUsd} in monthly inference credits. Adjust the number of licensed seats for your team.`} + title={teamCopy.addSeats.title} + description={formatTemplate(teamCopy.addSeats.description, { price: seatPriceUsd })} pricePerSeat={seatPriceUsd} minimumSeats={seatCount} maximumSeats={seatMaximum} @@ -727,7 +731,7 @@ export function TeamManagement() { setNewSeatCount(selectedSeats) await confirmAddSeats(selectedSeats) }} - confirmButtonText='Update Seats' + confirmButtonText={teamCopy.addSeats.confirm} showCostBreakdown={true} isCancelledAtPeriodEnd={subscriptionData?.cancelAtPeriodEnd} /> diff --git a/apps/tradinggoose/global-navbar/settings-modal/settings-dialog.tsx b/apps/tradinggoose/global-navbar/settings-modal/settings-dialog.tsx index 77e816713..e0055d13f 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/settings-dialog.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/settings-dialog.tsx @@ -1,6 +1,9 @@ 'use client' -import { type ReactNode, useMemo } from 'react' +import { type ReactNode } from 'react' +import { useLocale } from 'next-intl' +import { getPublicCopy } from '@/i18n/public-copy' +import { type LocaleCode } from '@/i18n/utils' import { AccountSettings } from './components/account/account-settings' import { ServiceSettings } from './components/service/service-settings' import { SSOSettings } from './components/sso/sso-settings' @@ -21,41 +24,37 @@ interface SectionRenderProps { } type SectionConfig = { - title: string render: (props: SectionRenderProps) => ReactNode } const SECTION_CONFIG: Record<SettingsSection, SectionConfig> = { account: { - title: 'Account Settings', render: () => <AccountSettings />, }, service: { - title: 'Service API Keys', render: () => <ServiceSettings />, }, subscription: { - title: 'Subscription', render: ({ onOpenChange }) => <SubscriptionSettings onOpenChange={onOpenChange} />, }, team: { - title: 'Team Management', render: ({ isActive }) => <TeamManagementSettings isActive={isActive} />, }, sso: { - title: 'Single Sign-On', render: ({ isActive }) => <SSOSettings isActive={isActive} />, }, } export function SettingsDialog({ open, section, onOpenChange }: SettingsDialogProps) { - const config = useMemo(() => SECTION_CONFIG[section], [section]) + const locale = useLocale() as LocaleCode + const settingsCopy = getPublicCopy(locale).workspace.settingsModal + const config = SECTION_CONFIG[section] return ( <SettingsModal open={open} onOpenChange={onOpenChange} - title={config.title} + title={settingsCopy.titles[section]} contentClassName='p-0' > {config.render({ isActive: open, onOpenChange })} diff --git a/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts b/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts index 402323543..f1b6fba49 100644 --- a/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts +++ b/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts @@ -4,6 +4,10 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +const { useLocaleMock } = vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'zh-CN'), +})) + const mockPush = vi.fn() let mockPathname = '/workspace/ws-1/dashboard' let mockSwitchToWorkspace = vi.fn() @@ -34,6 +38,10 @@ vi.mock('next/navigation', () => ({ }), })) +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: ( selector: (state: { switchToWorkspace: typeof mockSwitchToWorkspace }) => unknown @@ -49,6 +57,7 @@ describe('shouldResetWorkflowRegistryOnWorkspaceSwitch', () => { '@/global-navbar/use-workspace-switcher' ) expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/admin')).toBe(false) + expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/zh/admin')).toBe(false) expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/admin/integrations')).toBe(false) expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/login')).toBe(false) }) @@ -58,6 +67,9 @@ describe('shouldResetWorkflowRegistryOnWorkspaceSwitch', () => { '@/global-navbar/use-workspace-switcher' ) expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/workspace/ws-1/dashboard')).toBe(true) + expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/zh/workspace/ws-1/dashboard')).toBe( + true + ) expect(shouldResetWorkflowRegistryOnWorkspaceSwitch('/workspace/ws-1/w/wf-1')).toBe(true) }) }) @@ -66,7 +78,8 @@ describe('useWorkspaceSwitcher', () => { beforeEach(() => { mockPush.mockReset() mockSwitchToWorkspace = vi.fn() - mockPathname = '/workspace/ws-1/dashboard' + mockPathname = '/zh/workspace/ws-1/dashboard' + useLocaleMock.mockReturnValue('zh-CN') latestValue = null fetchMock = vi.fn(async () => ({ @@ -123,7 +136,24 @@ describe('useWorkspaceSwitcher', () => { expect(latestValue).not.toBeNull() expect(latestValue.canManageWorkspaces).toBe(true) expect(latestValue.activeWorkspace?.id).toBe('ws-1') - expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain('/api/workspaces') + + const workspacesCall = fetchMock.mock.calls.find(([url]) => String(url) === '/api/workspaces') + expect(workspacesCall).toBeDefined() + const requestInit = workspacesCall?.[1] as RequestInit | undefined + expect(new Headers(requestInit?.headers).get('x-next-intl-locale')).toBe('zh-CN') + + await act(async () => { + await latestValue.handleSwitchWorkspace({ + id: 'ws-2', + name: 'Workspace Two', + ownerId: 'user-1', + permissions: 'admin', + role: 'owner', + }) + }) + + expect(mockSwitchToWorkspace).not.toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/zh/workspace/ws-2/dashboard') await act(async () => { latestValue.setWorkspaceMenuOpen(true) @@ -135,7 +165,7 @@ describe('useWorkspaceSwitcher', () => { }) expect(latestValue.workspaceMenuOpen).toBe(true) - expect(latestValue.editingWorkspaceId).toBe('ws-1') + expect(latestValue.editingWorkspaceId).toBe('ws-2') expect(latestValue.inviteDialogOpen).toBe(true) expect(latestValue.deleteDialogOpen).toBe(true) }) diff --git a/apps/tradinggoose/global-navbar/use-workspace-switcher.ts b/apps/tradinggoose/global-navbar/use-workspace-switcher.ts index b3fc57542..c856e7844 100644 --- a/apps/tradinggoose/global-navbar/use-workspace-switcher.ts +++ b/apps/tradinggoose/global-navbar/use-workspace-switcher.ts @@ -2,7 +2,10 @@ import * as React from 'react' import { usePathname, useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' import { generateWorkspaceName } from '@/lib/naming' +import { getPublicCopy } from '@/i18n/public-copy' +import { buildLocaleRequestHeaders, localizeHref, type LocaleCode } from '@/i18n/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { Workspace } from './types' import { getWorkspaceIdFromPath, getWorkspaceSwitchPath } from './utils' @@ -18,6 +21,8 @@ export function shouldResetWorkflowRegistryOnWorkspaceSwitch(pathname: string): export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { const pathname = usePathname() ?? '/' const router = useRouter() + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.switcher const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace) const canManageWorkspaces = true const workspaceId = React.useMemo(() => getWorkspaceIdFromPath(pathname), [pathname]) @@ -48,7 +53,9 @@ export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { setIsWorkspacesLoading(true) try { - const response = await fetch('/api/workspaces') + const response = await fetch('/api/workspaces', { + headers: buildLocaleRequestHeaders(locale), + }) if (!response.ok) { setWorkspaces([]) setActiveWorkspace(null) @@ -77,7 +84,7 @@ export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { } finally { setIsWorkspacesLoading(false) } - }, [enabled, workspaceId]) + }, [enabled, locale, workspaceId]) React.useEffect(() => { void fetchWorkspaces() @@ -100,9 +107,9 @@ export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { } } - router.push(getWorkspaceSwitchPath(pathname, workspace.id)) + router.push(localizeHref(locale, getWorkspaceSwitchPath(pathname, workspace.id))) }, - [pathname, router, switchToWorkspace, workspaceId] + [locale, pathname, router, switchToWorkspace, workspaceId] ) const handleCreateWorkspace = React.useCallback(async () => { @@ -116,16 +123,18 @@ export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { setIsCreatingWorkspace(true) try { - const workspaceName = await generateWorkspaceName() + const workspaceName = await generateWorkspaceName(locale) const response = await fetch('/api/workspaces', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: buildLocaleRequestHeaders(locale, { + 'Content-Type': 'application/json', + }), body: JSON.stringify({ name: workspaceName }), }) if (!response.ok) { const error = await response.json().catch(() => null) - throw new Error(error?.error ?? 'Failed to create workspace') + throw new Error(error?.error ?? copy.failedToCreateWorkspace) } const data = await response.json() @@ -143,7 +152,7 @@ export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { } finally { setIsCreatingWorkspace(false) } - }, [canManageWorkspaces, fetchWorkspaces, handleSwitchWorkspace, isCreatingWorkspace]) + }, [canManageWorkspaces, copy.failedToCreateWorkspace, fetchWorkspaces, handleSwitchWorkspace, isCreatingWorkspace, locale]) const handleStartEditing = React.useCallback( (workspace: Workspace) => { @@ -194,18 +203,19 @@ export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { if (!response.ok) { const error = await response.json().catch(() => null) - throw new Error(error?.error ?? 'Failed to rename workspace') + throw new Error(error?.error ?? copy.failedToRenameWorkspace) } await fetchWorkspaces() handleCancelEditing() } catch (error) { - setRenameError(error instanceof Error ? error.message : 'Failed to rename workspace') + setRenameError(error instanceof Error ? error.message : copy.failedToRenameWorkspace) } finally { setIsRenamingWorkspace(false) } }, [ canManageWorkspaces, + copy.failedToRenameWorkspace, editingWorkspaceId, editingWorkspaceName, fetchWorkspaces, @@ -278,7 +288,7 @@ export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { if (!response.ok) { const error = await response.json().catch(() => null) - throw new Error(error?.error ?? 'Failed to delete workspace') + throw new Error(error?.error ?? copy.failedToDeleteWorkspace) } await fetchWorkspaces() @@ -287,12 +297,13 @@ export function useWorkspaceSwitcher({ enabled }: UseWorkspaceSwitcherOptions) { } handleDeleteDialogChange(false) } catch (error) { - setDeleteError(error instanceof Error ? error.message : 'Failed to delete workspace') + setDeleteError(error instanceof Error ? error.message : copy.failedToDeleteWorkspace) } finally { setIsDeletingWorkspace(false) } }, [ canManageWorkspaces, + copy.failedToDeleteWorkspace, workspaceToDelete, fetchWorkspaces, activeWorkspace?.id, diff --git a/apps/tradinggoose/global-navbar/utils.ts b/apps/tradinggoose/global-navbar/utils.ts index c506116bc..6062dab12 100644 --- a/apps/tradinggoose/global-navbar/utils.ts +++ b/apps/tradinggoose/global-navbar/utils.ts @@ -10,10 +10,13 @@ import { UserRoundPlus, Waypoints, } from 'lucide-react' +import { getPublicCopy } from '@/i18n/public-copy' +import { stripLocaleFromPathname, type LocaleCode } from '@/i18n/utils' import type { NavItemLink, NavSection } from './types' export function getWorkspaceIdFromPath(path: string) { - const match = /^\/workspace\/([^/]+)/.exec(path) + const { pathname } = stripLocaleFromPathname(path) + const match = /^\/workspace\/([^/]+)/.exec(pathname) return match?.[1] } @@ -22,7 +25,8 @@ export function getWorkspaceSwitchPath( targetWorkspaceId: string, searchParams?: string ) { - const match = /^\/workspace\/[^/]+(?:\/([^/]+))?/.exec(path) + const { pathname } = stripLocaleFromPathname(path) + const match = /^\/workspace\/[^/]+(?:\/([^/]+))?/.exec(pathname) const section = match?.[1] ?? null // Only allow safe top-level sections to carry over between workspaces. @@ -44,42 +48,47 @@ export function getWorkspaceSwitchPath( return normalizedSearch ? `${basePath}?${normalizedSearch}` : basePath } -export function createWorkspaceNav(workspaceId?: string): NavItemLink[] { +export function createWorkspaceNav(locale: LocaleCode, workspaceId?: string): NavItemLink[] { + const copy = getPublicCopy(locale).workspace.nav + if (!workspaceId) { return [ - { title: 'Dashboard', url: '/dashboard', icon: LayoutTemplate, section: 'workspace' }, - { title: 'Knowledge', url: '/knowledge', icon: LibraryBig, section: 'workspace' }, - { title: 'Files', url: '/files', icon: Files, section: 'workspace' }, - { title: 'Logs', url: '/logs', icon: Scroll, section: 'workspace' }, + { 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.logs, url: '/logs', icon: Scroll, section: 'workspace' }, ] } const base = `/workspace/${workspaceId}` return [ - { title: 'Dashboard', url: `${base}/dashboard`, icon: LayoutTemplate, section: 'workspace' }, - { title: 'Knowledge', url: `${base}/knowledge`, icon: LibraryBig, section: 'workspace' }, - { title: 'Files', url: `${base}/files`, icon: Files, section: 'workspace' }, - { title: 'Logs', url: `${base}/logs`, icon: Scroll, section: 'workspace' }, - { title: 'Environment Variable', url: `${base}/environment`, icon: Braces, section: 'more' }, - { title: 'API Keys', url: `${base}/api-keys`, icon: KeyRound, section: 'more' }, - { title: 'Integrations', url: `${base}/integrations`, icon: Waypoints, section: 'more' }, + { 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.logs, url: `${base}/logs`, icon: Scroll, 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' }, ] } -export function createAdminNav(): NavItemLink[] { +export function createAdminNav(locale: LocaleCode): NavItemLink[] { + const copy = getPublicCopy(locale).workspace.nav + return [ - { title: 'Overview', url: '/admin', icon: ShieldCheck, section: 'admin', match: 'exact' }, - { title: 'Billing', url: '/admin/billing', icon: Receipt, section: 'admin' }, - { title: 'Services', url: '/admin/services', icon: KeyRound, section: 'admin' }, - { title: 'Integrations', url: '/admin/integrations', icon: Waypoints, section: 'admin' }, - { title: 'Registration', url: '/admin/registration', icon: UserRoundPlus, section: 'admin' }, + { title: copy.admin.overview, url: '/admin', icon: ShieldCheck, section: 'admin', match: 'exact' }, + { title: copy.admin.billing, url: '/admin/billing', icon: Receipt, section: 'admin' }, + { title: copy.admin.services, url: '/admin/services', icon: KeyRound, section: 'admin' }, + { title: copy.admin.integrations, url: '/admin/integrations', icon: Waypoints, section: 'admin' }, + { title: copy.admin.registration, url: '/admin/registration', icon: UserRoundPlus, section: 'admin' }, ] } export function createNavSections(pathname: string, workspaceItems: NavItemLink[]): NavSection[] { + const { pathname: normalizedPathname } = stripLocaleFromPathname(pathname) return workspaceItems.map((item) => ({ ...item, - isActive: isPathActive(pathname, item.url, item.match), + isActive: isPathActive(normalizedPathname, item.url, item.match), })) } diff --git a/apps/tradinggoose/hooks/queries/listing-resolution.ts b/apps/tradinggoose/hooks/queries/listing-resolution.ts new file mode 100644 index 000000000..0dcdd0d15 --- /dev/null +++ b/apps/tradinggoose/hooks/queries/listing-resolution.ts @@ -0,0 +1,44 @@ +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + getListingIdentityKey, + type ListingIdentity, + type ListingResolved, + toListingValueObject, +} from '@/lib/listing/identity' +import { resolveListingIdentities } from '@/lib/listing/resolve' + +type UseResolvedListingsArgs = { + listings: readonly ListingIdentity[] + enabled?: boolean +} + +export const useResolvedListings = ({ listings, enabled = true }: UseResolvedListingsArgs) => { + const normalizedListings = useMemo(() => { + const seen = new Set<string>() + const next: ListingIdentity[] = [] + + for (const listing of listings) { + const normalized = toListingValueObject(listing) + if (!normalized) continue + const key = getListingIdentityKey(normalized) + if (seen.has(key)) continue + seen.add(key) + next.push(normalized) + } + + return next + }, [listings]) + + const listingKey = useMemo( + () => JSON.stringify(normalizedListings.map(getListingIdentityKey)), + [normalizedListings] + ) + + return useQuery<Record<string, ListingResolved | null>>({ + queryKey: ['listing-resolution', listingKey], + queryFn: ({ signal }) => resolveListingIdentities(normalizedListings, signal), + enabled: enabled && normalizedListings.length > 0, + staleTime: 5 * 60 * 1000, + }) +} diff --git a/apps/tradinggoose/hooks/queries/market-quote-snapshots.test.tsx b/apps/tradinggoose/hooks/queries/market-quote-snapshots.test.tsx new file mode 100644 index 000000000..afff312cf --- /dev/null +++ b/apps/tradinggoose/hooks/queries/market-quote-snapshots.test.tsx @@ -0,0 +1,187 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + useMarketQuoteSnapshots, + type MarketQuoteSnapshot, +} from '@/hooks/queries/market-quote-snapshots' + +const { socketMock } = vi.hoisted(() => ({ + socketMock: { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + handlers: new Map<string, Set<(payload?: any) => void>>(), + }, +})) + +vi.mock('@/contexts/socket-context', () => ({ + useSocket: () => ({ + socket: socketMock, + isConnected: true, + isConnecting: false, + }), +})) + +const listing = { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default' as const, +} + +const quoteSnapshot: MarketQuoteSnapshot = { + lastPrice: 101, + previousClose: 100, + change: 1, + changePercent: 1, +} + +const triggerSocketEvent = (event: string, payload?: any) => { + socketMock.handlers.get(event)?.forEach((handler) => handler(payload)) +} + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +} +const previousActEnvironment = reactActEnvironment.IS_REACT_ACT_ENVIRONMENT + +const Harness = ({ + onUpdate, +}: { + onUpdate: (data: ReturnType<typeof useMarketQuoteSnapshots>) => void +}) => { + const result = useMarketQuoteSnapshots({ + workspaceId: 'workspace-1', + provider: 'alpaca', + items: [ + { key: 'row-1', listing }, + { key: 'row-2', listing }, + ], + }) + + onUpdate(result) + return null +} + +describe('useMarketQuoteSnapshots', () => { + let container: HTMLDivElement + let root: Root + let unmounted: boolean + + beforeAll(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + }) + + beforeEach(() => { + socketMock.emit.mockReset() + socketMock.on.mockReset() + socketMock.off.mockReset() + socketMock.handlers.clear() + socketMock.on.mockImplementation((event: string, handler: (payload?: any) => void) => { + const handlers = socketMock.handlers.get(event) ?? new Set() + handlers.add(handler) + socketMock.handlers.set(event, handlers) + return socketMock + }) + socketMock.off.mockImplementation((event: string, handler: (payload?: any) => void) => { + socketMock.handlers.get(event)?.delete(handler) + return socketMock + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + unmounted = false + }) + + afterEach(() => { + if (!unmounted) { + act(() => { + root.unmount() + }) + } + container.remove() + }) + + afterAll(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment + }) + + it('subscribes once per canonical listing and maps snapshots back to widget aliases', async () => { + const updates: Array<ReturnType<typeof useMarketQuoteSnapshots>> = [] + + await act(async () => { + root.render(<Harness onUpdate={(result) => updates.push(result)} />) + }) + + const subscribeCalls = socketMock.emit.mock.calls.filter( + ([event]) => event === 'market-subscribe' + ) + expect(subscribeCalls).toHaveLength(1) + expect(subscribeCalls[0]?.[1]).toEqual( + expect.objectContaining({ + provider: 'alpaca', + workspaceId: 'workspace-1', + listing, + channel: 'quote-snapshots', + }) + ) + + const clientSubscriptionId = subscribeCalls[0]?.[1]?.clientSubscriptionId + await act(async () => { + triggerSocketEvent('market-quote-snapshot', { + provider: 'alpaca', + channel: 'quote-snapshots', + clientSubscriptionId, + listing, + snapshot: quoteSnapshot, + }) + }) + + const latest = updates[updates.length - 1] + expect(latest?.data).toEqual({ + 'row-1': quoteSnapshot, + 'row-2': quoteSnapshot, + }) + expect(latest?.isLoading).toBe(false) + }) + + it('uses server subscription ids for cleanup after subscribe ack', async () => { + await act(async () => { + root.render(<Harness onUpdate={() => undefined} />) + }) + + const subscribePayload = socketMock.emit.mock.calls.find( + ([event]) => event === 'market-subscribe' + )?.[1] + expect(subscribePayload?.clientSubscriptionId).toBeTruthy() + + await act(async () => { + triggerSocketEvent('market-subscribed', { + provider: 'alpaca', + channel: 'quote-snapshots', + subscriptionId: 'server-subscription-1', + clientSubscriptionId: subscribePayload.clientSubscriptionId, + }) + }) + + socketMock.emit.mockClear() + await act(async () => { + root.unmount() + }) + unmounted = true + + expect(socketMock.emit).toHaveBeenCalledWith('market-unsubscribe', { + subscriptionId: 'server-subscription-1', + }) + expect(socketMock.emit).not.toHaveBeenCalledWith( + 'market-unsubscribe', + expect.objectContaining({ + clientSubscriptionId: subscribePayload.clientSubscriptionId, + }) + ) + }) +}) diff --git a/apps/tradinggoose/hooks/queries/market-quote-snapshots.ts b/apps/tradinggoose/hooks/queries/market-quote-snapshots.ts new file mode 100644 index 000000000..de83723ea --- /dev/null +++ b/apps/tradinggoose/hooks/queries/market-quote-snapshots.ts @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useSocket } from '@/contexts/socket-context' +import { stableStringifyJsonValue } from '@/lib/json/stable' +import { + getListingIdentityKey, + type ListingIdentity, + toListingValueObject, +} from '@/lib/listing/identity' +import type { MarketQuoteSnapshot } from '@/lib/market/quote-snapshot-contract' + +export type { MarketQuoteSnapshot } from '@/lib/market/quote-snapshot-contract' + +type MarketQuoteSnapshotPayload = { + provider?: string + channel?: string + subscriptionId?: string + clientSubscriptionId?: string + listing?: ListingIdentity + snapshot?: MarketQuoteSnapshot +} + +type MarketSubscribedPayload = { + provider?: string + channel?: string + subscriptionId?: string + clientSubscriptionId?: string + listing?: ListingIdentity +} + +type MarketErrorPayload = { + provider?: string + channel?: string + subscriptionId?: string + clientSubscriptionId?: string + message?: string + error?: string +} + +export type UseMarketQuoteSnapshotsArgs = { + workspaceId?: string + provider?: string + items: Array<{ + key?: string + listing: ListingIdentity | null | undefined + }> + auth?: { + apiKey?: string + apiSecret?: string + } + providerParams?: Record<string, unknown> + refreshKey?: number | string | null + enabled?: boolean +} + +export const useMarketQuoteSnapshots = ({ + workspaceId, + provider, + items, + auth, + providerParams, + refreshKey, + enabled = true, +}: UseMarketQuoteSnapshotsArgs) => { + const { socket } = useSocket() + const [snapshotsByIdentity, setSnapshotsByIdentity] = useState<Record<string, MarketQuoteSnapshot>>( + {} + ) + const [error, setError] = useState<Error | null>(null) + const [pendingIdentityCount, setPendingIdentityCount] = useState(0) + const [refetchNonce, setRefetchNonce] = useState(0) + const runIdRef = useRef(0) + + const normalizedItems = useMemo(() => { + const seenKeys = new Set<string>() + const listingByIdentity = new Map<string, ListingIdentity>() + const aliasesByIdentity = new Map<string, string[]>() + + for (const entry of items) { + const listing = toListingValueObject(entry.listing) + if (!listing) continue + const identityKey = getListingIdentityKey(listing) + const key = + typeof entry.key === 'string' && entry.key.trim() + ? entry.key.trim() + : identityKey + if (seenKeys.has(key)) continue + + seenKeys.add(key) + if (!listingByIdentity.has(identityKey)) { + listingByIdentity.set(identityKey, listing) + } + const aliases = aliasesByIdentity.get(identityKey) ?? [] + aliases.push(key) + aliasesByIdentity.set(identityKey, aliases) + } + + return { + subscriptions: Array.from(listingByIdentity.entries()).map(([identityKey, listing]) => ({ + identityKey, + listing, + })), + aliasesByIdentity, + } + }, [items]) + + const subscriptionsKey = useMemo( + () => stableStringifyJsonValue(normalizedItems.subscriptions), + [normalizedItems.subscriptions] + ) + const authKey = stableStringifyJsonValue(auth ?? null) + const providerParamsKey = stableStringifyJsonValue(providerParams ?? null) + const shouldSubscribe = + enabled && + Boolean(workspaceId) && + Boolean(provider) && + normalizedItems.subscriptions.length > 0 + + useEffect(() => { + if (!shouldSubscribe) { + setPendingIdentityCount(0) + setError(null) + return + } + + if (!socket) { + setPendingIdentityCount(normalizedItems.subscriptions.length) + return + } + + let disposed = false + runIdRef.current += 1 + const runId = runIdRef.current + const receivedIdentities = new Set<string>() + const subscriptionIds = new Set<string>() + const acknowledgedClientSubscriptionIds = new Set<string>() + const identityByClientSubscriptionId = new Map<string, string>() + const clientSubscriptionIds = normalizedItems.subscriptions.map((item, index) => { + const clientSubscriptionId = `market-quote:${runId}:${index}:${item.identityKey}` + identityByClientSubscriptionId.set(clientSubscriptionId, item.identityKey) + return { + ...item, + clientSubscriptionId, + } + }) + + setPendingIdentityCount(clientSubscriptionIds.length) + setError(null) + + const markReceived = (identityKey: string) => { + if (receivedIdentities.has(identityKey)) return + receivedIdentities.add(identityKey) + setPendingIdentityCount((current) => Math.max(0, current - 1)) + } + + const resolvePayloadIdentity = (payload: { + clientSubscriptionId?: string + listing?: ListingIdentity + }) => { + const byClientId = payload.clientSubscriptionId + ? identityByClientSubscriptionId.get(payload.clientSubscriptionId) + : undefined + if (byClientId) return byClientId + + const listing = toListingValueObject(payload.listing) + return listing ? getListingIdentityKey(listing) : null + } + + const isRelevantProvider = (payloadProvider?: string) => + !payloadProvider || payloadProvider === provider + + const handleSubscribed = (payload: MarketSubscribedPayload) => { + if (disposed) return + if (payload.channel !== 'quote-snapshots') return + if (!isRelevantProvider(payload.provider)) return + if (!payload.clientSubscriptionId) return + if (!identityByClientSubscriptionId.has(payload.clientSubscriptionId)) return + acknowledgedClientSubscriptionIds.add(payload.clientSubscriptionId) + if (payload.subscriptionId) { + subscriptionIds.add(payload.subscriptionId) + } + } + + const handleSnapshot = (payload: MarketQuoteSnapshotPayload) => { + if (disposed) return + if (payload.channel !== 'quote-snapshots') return + if (!isRelevantProvider(payload.provider)) return + if (!payload.snapshot) return + const identityKey = resolvePayloadIdentity(payload) + if (!identityKey) return + + setSnapshotsByIdentity((current) => ({ + ...current, + [identityKey]: payload.snapshot as MarketQuoteSnapshot, + })) + markReceived(identityKey) + } + + const handleError = (payload: MarketErrorPayload) => { + if (disposed) return + if (payload.channel && payload.channel !== 'quote-snapshots') return + if (!isRelevantProvider(payload.provider)) return + if ( + payload.clientSubscriptionId && + !identityByClientSubscriptionId.has(payload.clientSubscriptionId) + ) { + return + } + + const message = + typeof payload.message === 'string' && payload.message.trim() + ? payload.message + : typeof payload.error === 'string' && payload.error.trim() + ? payload.error + : 'Failed to subscribe to market quotes' + setError(new Error(message)) + const identityKey = resolvePayloadIdentity(payload) + if (identityKey) markReceived(identityKey) + } + + const subscribeAll = () => { + subscriptionIds.clear() + for (const item of clientSubscriptionIds) { + socket.emit('market-subscribe', { + provider, + workspaceId, + listing: item.listing, + channel: 'quote-snapshots', + auth, + providerParams, + clientSubscriptionId: item.clientSubscriptionId, + }) + } + } + + socket.on('market-subscribed', handleSubscribed) + socket.on('market-quote-snapshot', handleSnapshot) + socket.on('market-error', handleError) + socket.on('market-subscribe-error', handleError) + socket.on('connect', subscribeAll) + subscribeAll() + + return () => { + disposed = true + socket.off('market-subscribed', handleSubscribed) + socket.off('market-quote-snapshot', handleSnapshot) + socket.off('market-error', handleError) + socket.off('market-subscribe-error', handleError) + socket.off('connect', subscribeAll) + for (const subscriptionId of subscriptionIds) { + socket.emit('market-unsubscribe', { subscriptionId }) + } + for (const item of clientSubscriptionIds) { + if (acknowledgedClientSubscriptionIds.has(item.clientSubscriptionId)) continue + socket.emit('market-unsubscribe', { + provider, + clientSubscriptionId: item.clientSubscriptionId, + }) + } + } + }, [ + authKey, + enabled, + provider, + providerParamsKey, + refetchNonce, + refreshKey, + shouldSubscribe, + socket, + subscriptionsKey, + workspaceId, + ]) + + const data = useMemo(() => { + const quotes: Record<string, MarketQuoteSnapshot> = {} + normalizedItems.aliasesByIdentity.forEach((aliases, identityKey) => { + const snapshot = snapshotsByIdentity[identityKey] + if (!snapshot) return + aliases.forEach((key) => { + quotes[key] = snapshot + }) + }) + return quotes + }, [normalizedItems.aliasesByIdentity, snapshotsByIdentity]) + + const refetch = useCallback(async () => { + setRefetchNonce((current) => current + 1) + return { data } + }, [data]) + + const isFetching = shouldSubscribe && pendingIdentityCount > 0 + + return { + data, + error, + isLoading: isFetching && Object.keys(data).length === 0, + isFetching, + refetch, + } +} diff --git a/apps/tradinggoose/hooks/queries/oauth-connections.ts b/apps/tradinggoose/hooks/queries/oauth-connections.ts index 1626fca69..969b559e2 100644 --- a/apps/tradinggoose/hooks/queries/oauth-connections.ts +++ b/apps/tradinggoose/hooks/queries/oauth-connections.ts @@ -73,27 +73,6 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> { } } - const connectionWithScopes = connections.find((conn: any) => { - if (!conn.baseProvider || !service.providerId.startsWith(conn.baseProvider)) { - return false - } - - if (conn.scopes && service.scopes) { - return service.scopes.every((scope) => conn.scopes.includes(scope)) - } - - return false - }) - - if (connectionWithScopes) { - return { - ...service, - isConnected: connectionWithScopes.accounts?.length > 0, - accounts: connectionWithScopes.accounts || [], - lastConnected: connectionWithScopes.lastConnected, - } - } - return service }) diff --git a/apps/tradinggoose/hooks/queries/oauth-credentials.ts b/apps/tradinggoose/hooks/queries/oauth-credentials.ts index 89fa01e87..a87e4a2e2 100644 --- a/apps/tradinggoose/hooks/queries/oauth-credentials.ts +++ b/apps/tradinggoose/hooks/queries/oauth-credentials.ts @@ -31,6 +31,8 @@ async function fetchJson<T>( export const oauthCredentialKeys = { list: (providerId?: string) => ['oauthCredentials', providerId ?? 'none'] as const, + listByProviderIds: (providerIds: string[]) => + ['oauthCredentialsByProviderIds', providerIds] as const, detail: (credentialId?: string, workflowId?: string) => ['oauthCredentialDetail', credentialId ?? 'none', workflowId ?? 'none'] as const, } @@ -66,6 +68,27 @@ export function useOAuthCredentials(providerId?: string, enabled = true) { }) } +export function useOAuthCredentialsByProviderIds(providerIds: string[], enabled = true) { + const normalizedProviderIds = Array.from( + new Set(providerIds.map((providerId) => providerId.trim()).filter(Boolean)) + ) + + return useQuery<Record<string, Credential[]>>({ + queryKey: oauthCredentialKeys.listByProviderIds(normalizedProviderIds), + queryFn: async () => { + const entries = await Promise.all( + normalizedProviderIds.map( + async (providerId) => [providerId, await fetchOAuthCredentials(providerId)] as const + ) + ) + + return Object.fromEntries(entries) + }, + enabled: normalizedProviderIds.length > 0 && enabled, + staleTime: 60 * 1000, + }) +} + export function useOAuthCredentialDetail( credentialId?: string, workflowId?: string, diff --git a/apps/tradinggoose/hooks/queries/oauth-provider-availability.ts b/apps/tradinggoose/hooks/queries/oauth-provider-availability.ts new file mode 100644 index 000000000..f7acaf3e4 --- /dev/null +++ b/apps/tradinggoose/hooks/queries/oauth-provider-availability.ts @@ -0,0 +1,45 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' + +export type OAuthProviderAvailability = Record<string, boolean> + +const normalizeProviderIds = (providerIds: string[]) => + Array.from(new Set(providerIds.map((providerId) => providerId.trim()).filter(Boolean))).sort() + +export const oauthProviderAvailabilityKeys = { + list: (providerIds: string[]) => + ['oauthProviderAvailability', ...normalizeProviderIds(providerIds)] as const, +} + +export async function fetchOAuthProviderAvailability( + providerIds: string[] +): Promise<OAuthProviderAvailability> { + const normalizedProviderIds = normalizeProviderIds(providerIds) + if (normalizedProviderIds.length === 0) { + return {} + } + + const query = `?providers=${encodeURIComponent(normalizedProviderIds.join(','))}` + const response = await fetch(`/api/auth/oauth/providers${query}`, { + cache: 'no-store', + }) + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`) + } + + return (await response.json()) as OAuthProviderAvailability +} + +export function useOAuthProviderAvailability(providerIds: string[], enabled = true) { + const normalizedProviderIds = normalizeProviderIds(providerIds) + + return useQuery<OAuthProviderAvailability>({ + queryKey: oauthProviderAvailabilityKeys.list(normalizedProviderIds), + queryFn: () => fetchOAuthProviderAvailability(normalizedProviderIds), + enabled: enabled && normalizedProviderIds.length > 0, + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + }) +} diff --git a/apps/tradinggoose/hooks/queries/trading-portfolio.ts b/apps/tradinggoose/hooks/queries/trading-portfolio.ts new file mode 100644 index 000000000..f3c6769be --- /dev/null +++ b/apps/tradinggoose/hooks/queries/trading-portfolio.ts @@ -0,0 +1,415 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import type { + QuickOrderSubmitRequest, + QuickOrderSubmitResponse, +} from '@/app/api/providers/trading/order/types' +import { useSocket } from '@/contexts/socket-context' +import type { + TradingPortfolioPerformanceWindow, + UnifiedTradingAccount, + UnifiedTradingAccountSnapshot, + UnifiedTradingPortfolioPerformance, + UnifiedTradingPositionListings, +} from '@/providers/trading/types' + +type TradingPortfolioChannel = 'accounts' | 'account-snapshot' | 'portfolio-performance' + +type TradingAccountsRequest = { + workspaceId?: string + provider?: string + credentialServiceId?: string + refreshKey?: number | string | null + enabled?: boolean +} + +type TradingSnapshotRequest = TradingAccountsRequest & { + accountId?: string +} + +type TradingPerformanceRequest = TradingSnapshotRequest & { + selectedWindow?: TradingPortfolioPerformanceWindow +} + +type TradingPortfolioSubscribedPayload = { + provider?: string + credentialServiceId?: string + workspaceId?: string + channel?: TradingPortfolioChannel + subscriptionId?: string + clientSubscriptionId?: string + accountId?: string + window?: TradingPortfolioPerformanceWindow +} + +type TradingPortfolioErrorPayload = TradingPortfolioSubscribedPayload & { + error?: string + message?: string +} + +type TradingPortfolioAccountsPayload = TradingPortfolioSubscribedPayload & { + channel?: 'accounts' + accounts?: UnifiedTradingAccount[] +} + +type TradingPortfolioSnapshotPayload = TradingPortfolioSubscribedPayload & { + channel?: 'account-snapshot' + snapshot?: UnifiedTradingAccountSnapshot + positionListings?: UnifiedTradingPositionListings['positionListings'] +} + +type TradingPortfolioPerformancePayload = TradingPortfolioSubscribedPayload & { + channel?: 'portfolio-performance' + performance?: UnifiedTradingPortfolioPerformance +} + +type TradingSocketQueryResult<T> = { + data: T | undefined + error: Error | null + isLoading: boolean + isFetching: boolean + refetch: () => Promise<{ data: T | undefined }> +} + +type SocketSubscriptionRef = { + subscriptionId?: string + clientSubscriptionId: string + provider: string + credentialServiceId?: string + workspaceId: string + channel: TradingPortfolioChannel + accountId?: string +} + +const getAccountsPayloadData = (payload: TradingPortfolioAccountsPayload) => payload.accounts + +const getSnapshotPayloadData = (payload: TradingPortfolioSnapshotPayload) => + payload.snapshot + ? { + snapshot: payload.snapshot, + positionListings: payload.positionListings ?? [], + } + : undefined + +const getPerformancePayloadData = (payload: TradingPortfolioPerformancePayload) => + payload.performance + +const postJson = async <T>(url: string, body: unknown): Promise<T> => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + const payload = (await response.json().catch(() => ({}))) as { + error?: string + } + + if (!response.ok) { + throw new Error(payload.error ?? `Request failed with status ${response.status}`) + } + + return payload as T +} + +function useTradingPortfolioSocketData<T>({ + channel, + provider, + credentialServiceId, + workspaceId, + accountId, + window, + refreshKey, + enabled = true, + dataEvent, + getData, +}: { + channel: TradingPortfolioChannel + provider?: string + credentialServiceId?: string + workspaceId?: string + accountId?: string + window?: TradingPortfolioPerformanceWindow + refreshKey?: number | string | null + enabled?: boolean + dataEvent: + | 'trading-portfolio-accounts' + | 'trading-portfolio-snapshot' + | 'trading-portfolio-performance' + getData: (payload: any) => T | undefined +}): TradingSocketQueryResult<T> { + const { socket } = useSocket() + const [dataState, setDataState] = useState<{ key: string; data: T | undefined }>({ + key: '', + data: undefined, + }) + const [error, setError] = useState<Error | null>(null) + const [isFetching, setIsFetching] = useState(false) + const [refetchNonce, setRefetchNonce] = useState(0) + const runIdRef = useRef(0) + const subscriptionRef = useRef<SocketSubscriptionRef | null>(null) + + const normalizedProvider = provider?.trim() + const normalizedCredentialServiceId = credentialServiceId?.trim() + const normalizedWorkspaceId = workspaceId?.trim() + const normalizedAccountId = accountId?.trim() + const requestKey = [ + channel, + normalizedWorkspaceId ?? '', + normalizedProvider ?? '', + normalizedCredentialServiceId ?? '', + normalizedAccountId ?? '', + window ?? '', + ].join('|') + const data = dataState.key === requestKey ? dataState.data : undefined + const shouldSubscribe = + enabled && + Boolean(normalizedProvider) && + Boolean(normalizedWorkspaceId) && + (channel === 'accounts' || Boolean(normalizedAccountId)) && + (channel !== 'portfolio-performance' || Boolean(window)) + + useEffect(() => { + subscriptionRef.current = null + + if (!shouldSubscribe) { + setDataState({ key: requestKey, data: undefined }) + setError(null) + setIsFetching(false) + return + } + + if (!socket) { + setError(null) + setIsFetching(true) + return + } + + let disposed = false + runIdRef.current += 1 + const runId = runIdRef.current + const clientSubscriptionId = [ + 'trading-portfolio', + channel, + runId, + normalizedProvider, + normalizedAccountId ?? 'accounts', + window ?? '', + ].join(':') + + subscriptionRef.current = { + clientSubscriptionId, + provider: normalizedProvider as string, + credentialServiceId: normalizedCredentialServiceId, + workspaceId: normalizedWorkspaceId as string, + channel, + accountId: normalizedAccountId, + } + + setDataState({ key: requestKey, data: undefined }) + setError(null) + setIsFetching(true) + + const isRelevantPayload = (payload: TradingPortfolioSubscribedPayload) => { + if (payload.channel && payload.channel !== channel) return false + if (payload.provider && payload.provider !== normalizedProvider) return false + if ( + payload.credentialServiceId && + normalizedCredentialServiceId && + payload.credentialServiceId !== normalizedCredentialServiceId + ) { + return false + } + if (payload.workspaceId && payload.workspaceId !== normalizedWorkspaceId) return false + if (payload.accountId && normalizedAccountId && payload.accountId !== normalizedAccountId) { + return false + } + if (payload.clientSubscriptionId) { + return payload.clientSubscriptionId === clientSubscriptionId + } + const currentSubscriptionId = subscriptionRef.current?.subscriptionId + if (payload.subscriptionId && currentSubscriptionId) { + return payload.subscriptionId === currentSubscriptionId + } + return false + } + + const subscribe = (forceRefresh = false) => { + socket.emit('trading-portfolio-subscribe', { + provider: normalizedProvider, + credentialServiceId: normalizedCredentialServiceId, + workspaceId: normalizedWorkspaceId, + channel, + accountId: normalizedAccountId, + window, + clientSubscriptionId, + forceRefresh, + }) + } + + const handleSubscribed = (payload: TradingPortfolioSubscribedPayload) => { + if (disposed || !isRelevantPayload(payload) || !payload.subscriptionId) return + subscriptionRef.current = { + ...(subscriptionRef.current as SocketSubscriptionRef), + subscriptionId: payload.subscriptionId, + } + } + + const handleData = (payload: unknown) => { + if (disposed || !isRelevantPayload(payload as TradingPortfolioSubscribedPayload)) return + const nextData = getData(payload) + if (nextData === undefined) return + setDataState({ key: requestKey, data: nextData }) + setError(null) + setIsFetching(false) + } + + const handleError = (payload: TradingPortfolioErrorPayload) => { + if (disposed || !isRelevantPayload(payload)) return + const message = + typeof payload.message === 'string' && payload.message.trim() + ? payload.message + : typeof payload.error === 'string' && payload.error.trim() + ? payload.error + : 'Failed to load trading portfolio data' + setError(new Error(message)) + setIsFetching(false) + } + + socket.on('trading-portfolio-subscribed', handleSubscribed) + socket.on(dataEvent, handleData) + socket.on('trading-portfolio-error', handleError) + socket.on('trading-portfolio-subscribe-error', handleError) + socket.on('connect', subscribe) + subscribe(refreshKey != null || refetchNonce > 0) + + return () => { + disposed = true + socket.off('trading-portfolio-subscribed', handleSubscribed) + socket.off(dataEvent, handleData) + socket.off('trading-portfolio-error', handleError) + socket.off('trading-portfolio-subscribe-error', handleError) + socket.off('connect', subscribe) + + const current = subscriptionRef.current + if (!current || current.clientSubscriptionId !== clientSubscriptionId) return + if (current.subscriptionId) { + socket.emit('trading-portfolio-unsubscribe', { + subscriptionId: current.subscriptionId, + }) + } else { + socket.emit('trading-portfolio-unsubscribe', { + provider: current.provider, + credentialServiceId: current.credentialServiceId, + channel: current.channel, + accountId: current.accountId, + clientSubscriptionId: current.clientSubscriptionId, + }) + } + subscriptionRef.current = null + } + }, [ + accountId, + channel, + credentialServiceId, + dataEvent, + enabled, + getData, + normalizedAccountId, + normalizedCredentialServiceId, + normalizedProvider, + normalizedWorkspaceId, + refetchNonce, + refreshKey, + requestKey, + shouldSubscribe, + socket, + window, + ]) + + const refetch = useCallback(async () => { + const current = subscriptionRef.current + if (socket && current) { + setIsFetching(true) + socket.emit('trading-portfolio-refresh', { + subscriptionId: current.subscriptionId, + clientSubscriptionId: current.clientSubscriptionId, + provider: current.provider, + credentialServiceId: current.credentialServiceId, + channel: current.channel, + accountId: current.accountId, + }) + } else { + setRefetchNonce((value) => value + 1) + } + return { data } + }, [data, socket]) + + return { + data, + error, + isLoading: shouldSubscribe && isFetching && data === undefined, + isFetching, + refetch, + } +} + +export function useTradingAccounts(request: TradingAccountsRequest) { + return useTradingPortfolioSocketData<UnifiedTradingAccount[]>({ + channel: 'accounts', + provider: request.provider, + credentialServiceId: request.credentialServiceId, + workspaceId: request.workspaceId, + refreshKey: request.refreshKey, + enabled: request.enabled, + dataEvent: 'trading-portfolio-accounts', + getData: getAccountsPayloadData, + }) +} + +export function useTradingPortfolioSnapshot(request: TradingSnapshotRequest) { + const result = useTradingPortfolioSocketData<{ + snapshot: UnifiedTradingAccountSnapshot + positionListings: UnifiedTradingPositionListings['positionListings'] + }>({ + channel: 'account-snapshot', + provider: request.provider, + credentialServiceId: request.credentialServiceId, + workspaceId: request.workspaceId, + accountId: request.accountId, + refreshKey: request.refreshKey, + enabled: request.enabled, + dataEvent: 'trading-portfolio-snapshot', + getData: getSnapshotPayloadData, + }) + + return { + ...result, + data: result.data?.snapshot, + positionListings: result.data?.positionListings ?? [], + } +} + +export function useTradingPortfolioPerformance(request: TradingPerformanceRequest) { + return useTradingPortfolioSocketData<UnifiedTradingPortfolioPerformance>({ + channel: 'portfolio-performance', + provider: request.provider, + credentialServiceId: request.credentialServiceId, + workspaceId: request.workspaceId, + accountId: request.accountId, + window: request.selectedWindow, + refreshKey: request.refreshKey, + enabled: request.enabled, + dataEvent: 'trading-portfolio-performance', + getData: getPerformancePayloadData, + }) +} + +export function useSubmitTradingOrder() { + return useMutation<QuickOrderSubmitResponse, Error, QuickOrderSubmitRequest>({ + mutationFn: (request) => + postJson<QuickOrderSubmitResponse>('/api/providers/trading/order', request), + }) +} diff --git a/apps/tradinggoose/hooks/queries/workspace.ts b/apps/tradinggoose/hooks/queries/workspace.ts index c292fcae4..52b903537 100644 --- a/apps/tradinggoose/hooks/queries/workspace.ts +++ b/apps/tradinggoose/hooks/queries/workspace.ts @@ -1,4 +1,6 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useLocale } from 'next-intl' +import { buildLocaleRequestHeaders, type LocaleCode } from '@/i18n/utils' /** * Query key factories for workspace-related queries @@ -10,7 +12,8 @@ export const workspaceKeys = { settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const, permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const, adminLists: () => [...workspaceKeys.all, 'adminList'] as const, - adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const, + adminList: (userId: string | undefined, locale: LocaleCode) => + [...workspaceKeys.adminLists(), userId ?? '', locale] as const, } /** @@ -135,12 +138,17 @@ export interface AdminWorkspace { /** * Fetch workspaces where user has admin access */ -async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWorkspace[]> { +async function fetchAdminWorkspaces( + userId: string | undefined, + locale: LocaleCode +): Promise<AdminWorkspace[]> { if (!userId) { return [] } - const workspacesResponse = await fetch('/api/workspaces') + const workspacesResponse = await fetch('/api/workspaces', { + headers: buildLocaleRequestHeaders(locale), + }) if (!workspacesResponse.ok) { throw new Error('Failed to fetch workspaces') } @@ -207,9 +215,11 @@ async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWo * Hook to fetch workspaces where user has admin access */ export function useAdminWorkspaces(userId: string | undefined) { + const locale = useLocale() as LocaleCode + return useQuery({ - queryKey: workspaceKeys.adminList(userId), - queryFn: () => fetchAdminWorkspaces(userId), + queryKey: workspaceKeys.adminList(userId, locale), + queryFn: () => fetchAdminWorkspaces(userId, locale), enabled: Boolean(userId), staleTime: 60 * 1000, // Cache for 60 seconds placeholderData: keepPreviousData, diff --git a/apps/tradinggoose/i18n.json b/apps/tradinggoose/i18n.json new file mode 100644 index 000000000..04ae978c5 --- /dev/null +++ b/apps/tradinggoose/i18n.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://lingo.dev/schema/i18n.json", + "version": "1.15", + "locale": { + "source": "en", + "targets": [ + "es", + "zh-CN" + ] + }, + "buckets": { + "json": { + "include": [ + "i18n/messages/[locale].json" + ] + } + } +} diff --git a/apps/tradinggoose/i18n.lock b/apps/tradinggoose/i18n.lock new file mode 100644 index 000000000..c4be1994d --- /dev/null +++ b/apps/tradinggoose/i18n.lock @@ -0,0 +1,1359 @@ +version: 1 +checksums: + f5d4e2268799b5de9987500ae1f6b7da: + meta/landing/title: 30c687dd32d747a21c9d12fbf118dcde + meta/landing/description: 449f9b7c631c94aaf9b7ea4683ea0e57 + meta/landing/openGraphTitle: 63e8cdae85c0f32414f72bdf70cc05e2 + meta/landing/openGraphDescription: b62e807221bf225dbc695c2a545ebb79 + meta/landing/seo/keywords: 389e05de2c1d548d24d9ff225235ae72 + meta/landing/seo/socialPreviewAlt: ba8a9473e4f6da9ede7395c3bab40ebe + meta/landing/seo/llmContentType: 1a167af35a634f956be931ab202aa80c + meta/landing/seo/llmUseCases: 6aab6a8b28d0983d919846b71b708c31 + meta/landing/seo/llmIntegrations: 2c7453a38db933c07017b36ef437649c + meta/landing/seo/llmPricing: 6284ecacfc006083b6a837d852c73262 + meta/blog/title: afc2b55f4ea3df5e2f0dadabedae6b01 + meta/blog/description: f9e16d57eb659fb4dab2071a383b1926 + meta/privacy/title: 34be7a2c366ee0d2ff02aca398489bf7 + meta/privacy/description: 97b71cd17e80349513c9f6cf42a89fe4 + nav/docs: 55fea190a0c0fb32acd3dc986cd3cc91 + nav/blog: 7feb9b36be2028520ffe37de06505148 + nav/login: f4f219abeb5a465ecb1c7efaf50246de + nav/menu: 520968a9770cddeb6d339c0fcbd70240 + nav/homeLabel: 104a3db3b671c04e167eafbe21e57881 + nav/languageLabel: 277fd1a41cc237a437cd1d5e4a80463b + registration/open/primary: 1d5f030c4ec9c869e647ae060518b948 + registration/open/auth: a6a0b995463f62ce6a12e37f1d05a9bd + registration/waitlist/primary: 391d2d5110cde46e1b053c109fc7754e + registration/waitlist/auth: df809925725e9ee8ae0b44c886601aa5 + registration/disabled/primary: ee2b0671e00972773210c5be5a9ccb89 + auth/common/email: e7f34943a0c2fb849db1839ff6ef5cb5 + auth/common/password: 223a61cf906ab9c40d22612c588dff48 + auth/common/fullName: f45991923345e8322c9ff8cd6b7e2b16 + auth/common/workEmail: 39c4d548a4af32515958f1c8a11fd05a + auth/common/enterYourEmail: 39931962707c99b99a5a073ab579396b + auth/common/enterYourPassword: ea4fdd034522dead21bae0c0abb52eae + auth/common/enterYourName: cd95fbdd0533f2c2e8edf9d9bd9aa8df + auth/common/enterYourWorkEmail: 31ff55771afe001968fb532d41e788d0 + auth/common/forgotPassword: 707454f0ef158b81060a11f2d9cbce57 + auth/common/showPassword: 8696d19a0f02613a86727810355fef47 + auth/common/hidePassword: dd9813264cfc4a7ae515cd5644943c1b + auth/common/continueWith: 11a7be95ef4cd680f510166b4d348c7f + auth/common/or: 98639bcbc7b4889bae62ad0ebde740e1 + auth/common/signIn: cb8757c7450e17de1e226e82fb0fa4a2 + auth/common/signUp: a6a0b995463f62ce6a12e37f1d05a9bd + auth/common/alreadyHaveAccount: 699043a787e26ec2ecad53f52df28d5b + auth/common/dontHaveAccount: 613efdc05ae6d04b7136d199645c56ac + auth/common/termsLeadSigningIn: 46ae257f6685e9df9c5bee5464808483 + auth/common/termsLeadCreatingAccount: 0cebefc68056445583b75c8ce3f5f7f0 + auth/common/termsOfService: 5add91f519e39025708e54a7eb7a9fc5 + auth/common/privacyPolicy: 7459744a63ef8af4e517a09024bd7c08 + auth/common/and: ce8b9bb44f031705708a70e068bb73c8 + auth/common/returnHome: 8abe6fe9fd9977e8388538cd12091fac + auth/common/backToLogin: 6bcc9859839f32dc2b4295e1565919a4 + auth/common/backToSignup: 04f5e1155c7ce278dbfe66c1664cfb71 + auth/common/verifyEmail: 42dcab68d931f9145d9b6d76740a5c66 + auth/common/signInWithEmail: 0e94ecdeb217052278976b6940d0aa4d + auth/common/signInWithSso: cbe9e890bb9350ba8c6c24702f6b3124 + auth/common/continueWithSso: c9c9cdf5a01426bb60dac238838ffdd3 + auth/common/requestAccess: 7ae2bbd44aa610b48972762b81e9da02 + auth/error/eyebrow: 4e6ad2de898424826d981d5a2cb46016 + auth/error/codeLabel: 9a472ea4d27476deba9166e62ff85ec2 + auth/error/supportPrefix: 7b09799de7a59ce1fdbeb3054e5a686d + auth/error/supportLinkLabel: e6f5dd9bae62d946a01eabe5debf7e3c + auth/error/supportSuffix: 0629a5532a3e38a97b59ffd8d38c3643 + auth/error/default/title: a3cd2f01c073f1f5ff436d4b132d39cf + auth/error/default/description: b790c9ad69a5152266d7dba144ab500b + auth/error/groups/accountCreation/title: a7a63ee5035a49a0ffcd3d50faa0a703 + auth/error/groups/accountCreation/description: 41950b0f3d8b06dadcf1faa82a516476 + auth/error/groups/accountExists/title: 4a69ebe869272e9c4cd822f0019602f5 + auth/error/groups/accountExists/description: abbbb8b81f9de050a1006ae3fd419763 + auth/error/groups/emailVerification/title: 4a81062b72ab95f3218e519c9001c3c7 + auth/error/groups/emailVerification/description: 233796353d1bba96c768f27d843166d0 + auth/error/groups/invalidCallback/title: de53be97c4e1a038866060ad23b78741 + auth/error/groups/invalidCallback/description: fa303883f8dd9c5fd386215905307526 + auth/error/groups/invalidToken/title: d6a2c0ecf63b9448c46ce15d543f75c3 + auth/error/groups/invalidToken/description: 17f30f55cbac277d5782a31bd01687d8 + auth/error/groups/expiredToken/title: a07c6cc8b568f947dbd8a302e7e73d95 + auth/error/groups/expiredToken/description: e669518a70ac2d11cb56011044d9d2c5 + auth/error/groups/sessionCreation/title: 0c8fd98f04bc7c7b12c98b44c81f4277 + auth/error/groups/sessionCreation/description: d97bedf9f846de30d6a9465a9f09d52f + auth/error/groups/sessionRestore/title: 76f2bbb0350cbdae4afd14efe9f3ab0e + auth/error/groups/sessionRestore/description: 6720ca249e4d43e1f1c51c51878f36ee + auth/error/groups/sessionExpired/title: 1300cd77684de51236252053a4d42f16 + auth/error/groups/sessionExpired/description: 71caf7d88bd9f667f0ea3414ba4a7b70 + auth/error/groups/userInfo/title: f65e287d65c5ebd89f8fd64c376b8a99 + auth/error/groups/userInfo/description: 15254751200267e2998d9585de4250f2 + auth/error/groups/providerUnavailable/title: c6e4b52c0fc065c42d0b2a2c6eb2e8f9 + auth/error/groups/providerUnavailable/description: 68f5d1a68076a1c792cc7f540b4ac6af + auth/error/groups/linkedAccount/title: 41ba54b756e8b93ddd8681043649e39c + auth/error/groups/linkedAccount/description: 8eb333667e124e4a26baebd2b3aa5cc7 + auth/error/groups/waitlistLimited/title: e91b333ded6ff8cc7ca1f92480c17998 + auth/error/groups/waitlistLimited/description: 4da6086f8d520cbb75fa5cd7b92b5559 + auth/error/groups/registrationDisabled/title: d551a14626f1caa71edf7a8887d91d63 + auth/error/groups/registrationDisabled/description: e2ef79c2d95ec9971442917c44939062 + auth/disabled/title: 6efae6253f5f4910b584cdc7bb527951 + auth/disabled/description: e2ef79c2d95ec9971442917c44939062 + auth/note/waitlistApprovedEmail: f8a1c29270ee5fffe5cdfc37e30260a6 + auth/social/github: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4 + auth/social/google: 6cc462fb53d90d404f48d5a6695c1de1 + auth/social/connecting: 0627ab04701831ecbde4135e1ef9210c + auth/verify/eyebrow: 33fc4ee308e79667653c2513ec043189 + auth/verify/pendingTitle: 1682e88a6eb7c961ae918fefc7325e2f + auth/verify/verifiedTitle: b84db813b6a3a3746ab9d77fb7060cc6 + auth/verify/verifiedDescription: bd415a6fff6bbdc6881fa1b38ec06665 + auth/verify/disabledDescription: 90d61a26ea386426304cf6ff85cd8733 + auth/verify/codeSent: 77d0bd995e5a9f688c38b2c93f570d50 + auth/verify/developmentDescription: 72948ab8d9646e76c28811a79c118afa + auth/verify/missingServiceDescription: 04e25fe25e2925d5ade706f24a8e33ee + auth/verify/instructionsWithService: 6bc9593e72952ebc17028f7b3670bb2c + auth/verify/instructionsWithoutService: 0d700329c3045d2fb928612c82a99a8c + auth/verify/verifyButton: a457d6441c3f26df9b32c639c23268c5 + auth/verify/verifyingButton: 421ed451e3e4b14dc2dcf25956fca011 + auth/verify/resendPrompt: 43fef05885f00bab29dfcfb6716a22bd + auth/verify/resendIn: f3e88f4eaf2d578d9860f3aa0b8b553b + auth/verify/resendButton: f420d288f862ba079434362ddb2e4e4e + auth/verify/yourEmail: 0adbfb5d57151e889cda8e83bb90402b + auth/verify/errors/invalid: e5cdb537fd9e81af45237e42fe2b5319 + auth/verify/errors/expired: 2fe547e115d8345ed0727a71527b551e + auth/verify/errors/generic: 7df1d2dc76bca6e6d0f91894c7159c37 + auth/verify/errors/attempts: 6aa57c9c4b984040c09587e8ae756245 + auth/verify/errors/resendFailed: 4049af452082ee7880c2ee67c5eb917d + auth/login/eyebrow: cb8757c7450e17de1e226e82fb0fa4a2 + auth/login/title: 4928884739ba559e6e4b960e80fe1452 + auth/login/description: 6ddd6e3b2949d8f486cf34fb2699470d + auth/login/submit: cb8757c7450e17de1e226e82fb0fa4a2 + auth/login/submitting: 8c7c297eaa8691114c8af70e53bb5fe6 + auth/login/divider: 11a7be95ef4cd680f510166b4d348c7f + auth/login/resetDialog/title: 52ec990b25991808a4f49db84986eefc + auth/login/resetDialog/description: 9294fabd583de0c8f7f9b907c7a553b3 + auth/login/resetDialog/emailLabel: e7f34943a0c2fb849db1839ff6ef5cb5 + auth/login/resetDialog/emailPlaceholder: 39931962707c99b99a5a073ab579396b + auth/login/resetDialog/emailRequired: 1955a66915d1dc9c186ca965813671d8 + auth/login/resetDialog/emailInvalid: 4157c206eb09e5f58cd1804be8689c55 + auth/login/resetDialog/success: 4515342d29cfe5d4c6dd843804b572f6 + auth/login/resetDialog/error: bbe9d833da0b01dee0f3cbdd77e704c3 + auth/login/resetDialog/submit: ba9436609c93e3ae32a3c6c279fb9c76 + auth/login/resetDialog/submitting: 313baf79716ca9d57de864eaac4a602a + auth/login/validation/emailRequired: f9dca63bd8dbc790c05d33d7219888c8 + auth/login/validation/emailInvalid: 4157c206eb09e5f58cd1804be8689c55 + auth/login/validation/passwordRequired: 165b135f6d7bf54ffba088796fb3f25b + auth/login/validation/passwordEmpty: 85367e7cbec81ac8c7212f74768b2cb1 + auth/login/errors/sessionExpired: 05459e19327800976bb5e10a54594a0d + auth/login/errors/emailSignInDisabled: f448a4b48b8e6f0985b8a41745dac99c + auth/login/errors/invalidCredentials: 6175c22a0c1b1756b1924982b52b60f4 + auth/login/errors/noAccount: 44be5bfa13e482bd55922fc568f24327 + auth/login/errors/missingCredentials: e013efa526f47bc4b14db99f5487eaca + auth/login/errors/emailPasswordDisabled: 6b251dc6e8005d23047d7dd2f59caef2 + auth/login/errors/failedToCreateSession: 78b10a07cf960b9ab9c76b275abb86ad + auth/login/errors/tooManyAttempts: 5dc9ecb833f347585a529ed078263f45 + auth/login/errors/accountLocked: a7272485e74841e7cd319a1f00df0095 + auth/login/errors/network: 84dfe4a8bfc80d00d938c9fbdcbd1c54 + auth/login/errors/rateLimit: c9ccd4036fd8d2d745c56f6c6572cc4f + auth/login/errors/unableToSignIn: c64eda8a9e5ee8d36885e25706f501c6 + auth/login/errors/unableToSignInNow: 2b373d1b7d0e8e2e7965c5046ae9b654 + auth/signup/eyebrow: a6a0b995463f62ce6a12e37f1d05a9bd + auth/signup/title: bc53a57281d2701563f94f3169518028 + auth/signup/descriptionOpen: 48b848aa16f874478ce0eef559371846 + auth/signup/descriptionWaitlist: ac1dc702b3bcc091a6ac92b1de8eef29 + auth/signup/submit: 6a11810d313098655e600e0303d455fd + auth/signup/submitting: bc93fb85564815cb9131b8f59e98a500 + auth/signup/divider: 11a7be95ef4cd680f510166b4d348c7f + auth/signup/nameTitle: d15807d14a6bd9cd68d105be8b12158f + auth/signup/validation/emailRequired: f9dca63bd8dbc790c05d33d7219888c8 + auth/signup/validation/emailInvalid: 4157c206eb09e5f58cd1804be8689c55 + auth/signup/validation/nameRequired: e94e5e58f8c59721f5b2d6da1d4d2d09 + auth/signup/validation/nameEmpty: 5c21338580418f528f14cd8ff1fd48ef + auth/signup/validation/nameCharacters: 8d0a3900692c721086de786277a60abb + auth/signup/validation/nameSpaces: 4d011d6665c1b0463c50c6ad89e3ecb4 + auth/signup/validation/nameTooLong: 98e8cc29a0b4bcd7d4c0da90eb027d9a + auth/signup/validation/passwordMinLength: a79a865f096cd6ec5bc0a1f977e73d72 + auth/signup/validation/passwordUppercase: a24792d6b2fd785195b3fb6b991cf8b8 + auth/signup/validation/passwordLowercase: 507999bd66d1c78b085564123d7a2efb + auth/signup/validation/passwordNumber: 96f84b4442cf27e41d4877151f9ec762 + auth/signup/validation/passwordSpecial: 7fb59d3d4654afb5ccc3faa6307826c2 + auth/signup/errors/failedToCreateAccount: 59edc349370f3bbf62b54e2800cd6ddc + auth/signup/errors/accountExists: 627d9ff4fa4a5eb91b5f160e0a4c031b + auth/signup/errors/emailSignupDisabled: 75d8af126604322cc19f92a931138596 + auth/signup/errors/signupNotEnabled: 67dab5a5448b2e6959435d81b8c91b51 + auth/signup/errors/waitlistRequired: 855ace07cb01129325bd951f1fa7f115 + auth/signup/errors/invalidEmail: 4157c206eb09e5f58cd1804be8689c55 + auth/signup/errors/passwordTooShort: a79a865f096cd6ec5bc0a1f977e73d72 + auth/signup/errors/passwordTooLong: 9bf340c9db4c6d1d6c95b0256638d84b + auth/signup/errors/network: 84dfe4a8bfc80d00d938c9fbdcbd1c54 + auth/signup/errors/rateLimit: c9ccd4036fd8d2d745c56f6c6572cc4f + auth/waitlist/eyebrow: c8657dfb4a651a80a97d32064b813796 + auth/waitlist/title: db54d46d4756b72560edc461a8cc6042 + auth/waitlist/description: 330936dd28dddc62a028c74925d5079a + auth/waitlist/helperText: af9c5000a798393fc87c8219338a3c52 + auth/waitlist/submit: 7ae2bbd44aa610b48972762b81e9da02 + auth/waitlist/submitting: 6845d212e994f3023c5287bdd3b74875 + auth/waitlist/pending: e9ff67d35476f7feadf50f0f901f0ae5 + auth/waitlist/approvedPrefix: 95ef9fafd7a0978954e7b40850f03e93 + auth/waitlist/signedUpPrefix: a9004e9b5db49a62debf2a274f7a97f0 + auth/waitlist/rejected: 42250067f82ae2835d029f13a85ca704 + auth/waitlist/signUpLink: 5a85f9c51cd42bf2470396a29cf88699 + auth/waitlist/loginLink: 73aa720d683876819c86c941e8b4350e + auth/waitlist/validation/emailRequired: f9dca63bd8dbc790c05d33d7219888c8 + auth/waitlist/validation/emailInvalid: 4157c206eb09e5f58cd1804be8689c55 + auth/sso/eyebrow: 003f7f93d44b0f5c2e40bf24e7af3bd7 + auth/sso/title: cbe9e890bb9350ba8c6c24702f6b3124 + auth/sso/description: dcd9b2370089739ce1f643e3e2941620 + auth/sso/submit: c9c9cdf5a01426bb60dac238838ffdd3 + auth/sso/submitting: 7137f5503019a2367db4a609ec32d6be + auth/sso/divider: 98639bcbc7b4889bae62ad0ebde740e1 + auth/sso/emailButton: 0e94ecdeb217052278976b6940d0aa4d + auth/sso/validation/emailRequired: f9dca63bd8dbc790c05d33d7219888c8 + auth/sso/validation/emailInvalid: 4157c206eb09e5f58cd1804be8689c55 + auth/sso/errors/accountNotFound: a759e807831b3dc74406cb08b1230919 + auth/sso/errors/ssoFailed: 40ab806cdb4355eb0ef8569eca7e7737 + auth/sso/errors/providerNotConfigured: d7deb0d572551613388f4a3270603e12 + auth/sso/errors/invalidEmailDomain: eda9614bce3d9a9eb710b7dcecce5f04 + auth/sso/errors/network: 84dfe4a8bfc80d00d938c9fbdcbd1c54 + auth/sso/errors/rateLimit: c9ccd4036fd8d2d745c56f6c6572cc4f + auth/sso/errors/ssoDisabled: ed63f1e4de885191846a752c61468713 + auth/sso/errors/failed: 2f842ba4fc0d911dd70ef38c5385e5c9 + localeNames/en: e1eeeb379f86a015d6e581fcbc4af7f3 + localeNames/es: e1cd9645ddb854071941a3a23af9eaaf + localeNames/zh-CN: 56d7ebc7e245c2150d34cd2908dec619 + landing/hero/statusBadges/disabled: 90d4faa8512f2689f3a89eaa8967d048 + landing/hero/statusBadges/waitlist: 89b7644e577453acdacd629a6c05791e + landing/hero/statusBadges/open: ced2762b03cc4428c666f84b01120cf9 + landing/hero/leadWords/0: b9133d1d6f232e6d15178f12bbae79aa + landing/hero/leadWords/1: 82a46f197779af4dde92e7e5f9336ccf + landing/hero/leadWords/2: cc02693eda0a4aa0c1ab356a79e8afb8 + landing/hero/highlightWords/0: 97079fb8ccd24e331d6a3aa03fc56b00 + landing/hero/highlightWords/1: 03d8fb9cb44e15eaf5c082e54acf7c58 + landing/hero/highlightWords/2: b5efaf895aa6b3d8eba91b64816703fb + landing/hero/titleConnector: b087281d153095f3667a0610063e4d90 + landing/hero/suffix: 7d515a2d85c70e52c08fe8a7b6461deb + landing/hero/description: ce5e84858f60a67232af2859ce26b04f + landing/hero/featureBadges/0: 8d861ec47ee608ca6a5889d30a2384da + landing/hero/featureBadges/1: 24cfc2eaea4d79605b3f72f40f14b01c + landing/hero/featureBadges/2: 346abcba0542d149bc352c01f5c70ad8 + landing/hero/featureBadges/3: 0ccce343287704cd90150c32e2fcad36 + landing/hero/learnMore: 1d49a231c2f3fe8f1fd3997a9e74fdef + landing/cta/title: 292315125641f74d118abb2d39c8e549 + landing/cta/description: f8c1d57130c5de35aac0c9ecb972e958 + landing/cta/joinDiscord: e08eae264d85751753a88b7796aeda77 + landing/cta/placeholder: 3cbb854500cac63513492e7abbc5cff3 + landing/cta/subscribe: cdb1ea0c982130d4f0df8381438affb7 + landing/cta/subscribing: b952c3542d5aac897c214be948adbad3 + landing/cta/success: de066683e51f9b281d4e7cdfba8e4c77 + landing/cta/error: d2178e82ee8359dea326f25cbe89018b + landing/footer/description: 6fddf72a07d79825f28f8fa073ea2e15 + landing/footer/copyright: 121ce84461ab67021e65ce42274248b1 + landing/footer/links/docs: 55fea190a0c0fb32acd3dc986cd3cc91 + landing/footer/links/blog: 7feb9b36be2028520ffe37de06505148 + landing/footer/links/widgets: 9f8e0fb68a48051075f979ab2758b5f1 + landing/footer/links/indicators: 6ba16bc4a2ba376582a8f1bc3612a58f + landing/footer/links/blocks: 9ca4811d4b82738ea9912bc42e425893 + landing/footer/links/tools: b97917281ca74e5b9de6122632979796 + landing/footer/links/changelog: 2a0cc1faf653e9ca97786acee96ffbde + landing/footer/links/privacy: 7459744a63ef8af4e517a09024bd7c08 + landing/footer/links/licenses: 3ecba1dd04ba9d5ba72f1c810f9f1ade + landing/footer/links/terms: 5add91f519e39025708e54a7eb7a9fc5 + landing/footer/social/discord: 06ec7d95ab44931ed9d1925e4063d703 + landing/footer/social/github: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4 + landing/footer/hoverText: 3fb54d99c3eb5ba376ae6c5ded9d8472 + landing/preview/shell/headerAriaLabel: 346f6fb8ff93d71d924611912b1ce45a + landing/preview/shell/widgetLabel: c624dad2dbe185e7cac2da846e381694 + landing/preview/layout/headerAriaLabel: 346f6fb8ff93d71d924611912b1ce45a + landing/preview/layout/sizeLabel: a8aae454ee97f62610f92084d34ce866 + landing/preview/indicatorDropdown/placeholder: 6ae1695ce3f0c2c7c7624d68a2ee90f9 + landing/preview/indicatorDropdown/tooltip: 6ae1695ce3f0c2c7c7624d68a2ee90f9 + landing/preview/indicatorDropdown/searchPlaceholder: 1533dcb1cf16918b54c4fe2941b1b6a7 + landing/preview/indicatorDropdown/emptyWithQuery: 70fb7787461f501376d7884dd99b79b3 + landing/preview/indicatorDropdown/emptyWithoutQuery: fd558f3c84fddd4cf5ee9247779a1c02 + landing/preview/market/indicatorUnavailableError: 713d0b666a732b009d39e484286b1ba4 + landing/preview/workflow/zoomOut: c7d644a4376f5468873f657e6936d085 + landing/preview/workflow/zoomIn: a60092edfd337bd30f0d43e2769e460d + landing/preview/workflow/selectorAriaLabel: f45cc4636c18f14c6cd4fc46bbf1a2d6 + landing/preview/workflow/demos/signalBriefing: 7d19706aaea5b95abf255ffd0f450c5e + landing/preview/workflow/demos/investmentDebate: 5724d3f3607e788fab748c02dfdd369d + landing/preview/workflow/demos/riskRouting: a30a5b9f50081a37ee5e928f55bc9892 + landing/howItWorks/eyebrow: 191d4f7f162cdb17f444531b7593c549 + landing/howItWorks/title: 6fead2172064af47af40d88f46c889d0 + landing/howItWorks/description: 6172566c5a63c5b9cefec8ab109b4c6d + landing/howItWorks/processes/0/title: 52b2e3d65aa34630f7974f5cfe91dffb + landing/howItWorks/processes/0/description: 04edba6aedcc59d7d98ccc193714f6bf + landing/howItWorks/processes/1/title: 57118ed63a89add01ce8d28d644e20bb + landing/howItWorks/processes/1/description: e0d26e06b1ebd7b7fbc2967f377afbe8 + landing/howItWorks/processes/2/title: da3c7ab49a70e48df8d3ab022d163397 + landing/howItWorks/processes/2/description: 3f18d81d1f4d5e233f4a55a9da0142a6 + landing/howItWorks/processes/3/title: e707b7e674020878fbddf5545464a549 + landing/howItWorks/processes/3/description: d85258e69f8177d2f942b7812dc53565 + landing/monitorSection/eyebrow: cf764be11a6cb5054e487a3e48a41f3d + landing/monitorSection/title: 0486008945851ebab6133ca106b7c397 + landing/monitorSection/description: 7124031e05f3f6a113f0783fa113e826 + landing/monitorSection/bullets/0: b3a1970aafee0f114fd38e0fc9478157 + landing/monitorSection/bullets/1: cec1f0d73e99eede6be7d12c5144a21f + landing/monitorSection/bullets/2: 26accc53cd278b17b866dd9d5201165c + landing/monitorSection/tableHeaders/listing: 33e04d58d3fe9dab6b3d0ddbeb105a58 + landing/monitorSection/tableHeaders/indicator: a9fe0ddef53545d72f40a2b0bef23288 + landing/monitorSection/tableHeaders/workflow: 0240772c838117f1c837f4913fce9384 + landing/monitorSection/tableHeaders/status: 4e1fcce15854d824919b4a582c697c90 + landing/monitorSection/statuses/pending: 030a6f3395d5d4efddd3cc67d6009039 + landing/monitorSection/statuses/running: 010d4795c3d5df31edde92a3441d7017 + landing/monitorSection/statuses/success: c43827becada6750f7a25890905f38b9 + landing/monitorSection/statuses/failed: 99f87615af1fffa2b8802866b096705a + landing/monitorSection/indicatorOptions/0/name: c902d164e98f67dbf6d367381778e55c + landing/monitorSection/indicatorOptions/0/color: b09376eb4163755dd099411d98b7acb4 + landing/monitorSection/indicatorOptions/1/name: 66465f10b1d96ce41dd86621e32b3ecb + landing/monitorSection/indicatorOptions/1/color: 284b9ca365ecd66dc7a1cda9c5f274ab + landing/monitorSection/indicatorOptions/2/name: 48ea6637d560912d8b173967b5a1719c + landing/monitorSection/indicatorOptions/2/color: ecdafc57f0e44ea033a279c98e85a828 + landing/monitorSection/indicatorOptions/3/name: a64c0128861fb1b8494c361e9f8a4e65 + landing/monitorSection/indicatorOptions/3/color: 030061f82d75478ef61ae60458468e0a + landing/monitorSection/indicatorOptions/4/name: d49d0248aa2f9bdc8b4f7cf4f8d40873 + landing/monitorSection/indicatorOptions/4/color: c2d9d4b5af16ef3eb1e793e95a54bd8c + landing/monitorSection/indicatorOptions/5/name: d29e11df55704da711260de8a450fe61 + landing/monitorSection/indicatorOptions/5/color: e79a3495585e1570cd93898c1a666813 + landing/monitorSection/workflowOptions/0/name: cbf8db3fc776734c49f5ea2f97e201c0 + landing/monitorSection/workflowOptions/0/color: 5656d24cc8071a76014bb38313614697 + landing/monitorSection/workflowOptions/1/name: b5efaf895aa6b3d8eba91b64816703fb + landing/monitorSection/workflowOptions/1/color: ecdafc57f0e44ea033a279c98e85a828 + landing/monitorSection/workflowOptions/2/name: 128dd65392ce84b484de60ec82427ad3 + landing/monitorSection/workflowOptions/2/color: 497434bc35eafabf06774455ece7381e + landing/monitorSection/workflowOptions/3/name: 09c8c7d406557f245b57d205e6751db4 + landing/monitorSection/workflowOptions/3/color: c2d9d4b5af16ef3eb1e793e95a54bd8c + landing/monitorSection/workflowOptions/4/name: 5643e15b1496faf45e8c220afc54e63d + landing/monitorSection/workflowOptions/4/color: b09376eb4163755dd099411d98b7acb4 + landing/monitorSection/workflowOptions/5/name: 77184ffd015454e126fa64c0df23f242 + landing/monitorSection/workflowOptions/5/color: 030061f82d75478ef61ae60458468e0a + landing/monitorSection/workflowOptions/6/name: d82f5e9af939d222a60cea3dcda40e38 + landing/monitorSection/workflowOptions/6/color: 284b9ca365ecd66dc7a1cda9c5f274ab + landing/features/eyebrow: 341ff316a339b106a178f0b8d362951b + landing/features/title: 87f84f0e058b21f42c8f35bd1c9d2972 + landing/features/description: ed54cac9859869f0f9140c01642c4061 + landing/features/rows/0/badge: b63ef0e99ee6f7fef6cbe4971ca6cf0f + landing/features/rows/0/title: dbaf3ed1722005e7470e16c81211ed56 + landing/features/rows/0/description: fa45a9826d275048174a11177f2550f1 + landing/features/rows/0/bullets/0: 0ca0cc820efc2aa02086c1c138aec609 + landing/features/rows/0/bullets/1: 5a7c04f190cbe393642997b886a41d94 + landing/features/rows/0/bullets/2: 4ee2a91752b70e496305b958868dbb6d + landing/features/rows/1/badge: 29ed2bb569a77150c4251768718fe2fc + landing/features/rows/1/title: 462a46bb19bb0b9f0a3c12b864b1338d + landing/features/rows/1/description: 4c0ec1aef73dc4a8dc184b033f8514e9 + landing/features/rows/1/bullets/0: b3ad9ac920daef87604a611a97ed3e76 + landing/features/rows/1/bullets/1: a9ccc11657737e91bc779881ab9dc5dc + landing/features/rows/1/bullets/2: 4faa279d73c1de6377dbd42acecc2d8f + landing/features/rows/2/badge: b0c9c8615a9ba7d9cb73e767290a7f72 + landing/features/rows/2/title: ca8237df602d5839c8ddfe08083fd581 + landing/features/rows/2/description: 24f057039ea089a0d1d5c72a64041a9e + landing/features/rows/2/bullets/0: 40d4e8eba04631df9741c37a48085d2d + landing/features/rows/2/bullets/1: 9a764a70e3d6c0358827dca04a31b4b6 + landing/features/rows/2/bullets/2: 110af322142a6fe1f2d54bc7e12ef301 + landing/integrations/eyebrow: 0ccce343287704cd90150c32e2fcad36 + landing/integrations/title: 49df52b0b224664f68ea9e596136972d + landing/integrations/bullets/0: df4914465cf92e84131ae8245b75117c + landing/integrations/bullets/1: 3a40aafd5225af063f188e05778ac2c3 + landing/integrations/bullets/2: 24022bbfdeab42aecdb2e18864f62c37 + landing/integrations/structuredData/name: e513f7a0c43724c1823124cc3c059086 + landing/integrations/structuredData/description: 9a6af1859f0560a464d4d9e63b48423d + blog/pageTitle: 7feb9b36be2028520ffe37de06505148 + blog/pageDescription: 5a22e266c5d9fe7c5ef5d3871d8be368 + blog/searchPlaceholder: bcb669d0801480b1ddae3a3e7bea1bc2 + blog/emptyTitle: 1c6f7fa455bd41728ae55f1d18708768 + blog/emptyDescription: e7335b22ef530aaa558ba62097f42ce2 + blog/noMatches: 58fd9ea2617bde25392b58440aacf983 + blog/noMatchesDescription: 8b7734bcec228234e470f87c9e27af0f + blog/readTimeSuffix: 9bcad48201d432d7af75055c81a4eb81 + blog/viewArticle: d8fe04650602dca3564d5794c688affd + blog/home: 104a3db3b671c04e167eafbe21e57881 + blog/breadcrumbBlog: 7feb9b36be2028520ffe37de06505148 + blog/articleSingular: cdcafe74dbfb2dba61bc7a15ef8e59f7 + blog/articlePlural: 7a0bc80225c932e95ccf81ec196ad97a + privacy/title: 7459744a63ef8af4e517a09024bd7c08 + privacy/description: 97b71cd17e80349513c9f6cf42a89fe4 + privacy/lastUpdatedLabel: c1d0ce09cebda9987970976568b06748 + privacy/lastUpdated: 218b2f2059227627863e9f8d22fa20c6 + workspace/entry/loading: b59d7ed7a86046b830f32810b78609f2 + workspace/switcher/failedToCreateWorkspace: 4b0e1da0e1ff3e888d7d7e232aaf45e4 + workspace/switcher/failedToRenameWorkspace: b7dd6ba953d349f48c923b0679184c53 + workspace/switcher/failedToDeleteWorkspace: 812a04ca33578cb48468e3b0fa8624c5 + workspace/switcher/roles/owner: 9c0b3aeca046c3ebd3bdc352edc83e88 + workspace/switcher/roles/admin: 90eb20f1400db82ab874744e47836dc6 + workspace/switcher/roles/member: 1606dc30b369856b9dba1fe9aec425d2 + workspace/switcher/roles/read: ed9df6b7e1b86f2ddfe41c94403223b8 + workspace/switcher/workspaceLabel: b63ef0e99ee6f7fef6cbe4971ca6cf0f + workspace/switcher/noWorkspacesYet: 85caf16a7c77778d4cefbe7d2bacfe16 + workspace/switcher/noWorkspacesAvailable: e51ba0f367dd35e3583b63eb93b5b2e8 + workspace/switcher/manage: a3d40c0267b81ae53c9598eaeb05087d + workspace/switcher/create: 757ccd28dd533ff3a933355273c1e32a + workspace/switcher/creating: c949fe6aa47b326f345b405fe9c4d6e7 + workspace/nav/groups/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f + workspace/nav/groups/system: 803281c327a02a2feba182f4a2b38311 + workspace/nav/groups/more: ee5e035ee328a8947be2ea44e338a57d + workspace/nav/workspace/dashboard: c9380ea68c8c76ea451bd9613329a07c + workspace/nav/workspace/knowledge: 434d6cd2ab58b3fcfca0d49e0b354c8b + workspace/nav/workspace/files: 30928ef964dea8a097cf810d3bc35ab6 + workspace/nav/workspace/logs: cc9e9e82182a1b8173d7cf321b811683 + workspace/nav/more/environment: 538ccbbb5bae1e2b53c526e8e06feef3 + workspace/nav/more/apiKeys: f961b547cd312cc8b9b79f0c9e0b2cc3 + workspace/nav/more/integrations: 0ccce343287704cd90150c32e2fcad36 + workspace/nav/admin/overview: 30c54e4dc4ce599b87d94be34a8617f5 + workspace/nav/admin/billing: b01dbdd049ebbd4a349fa64d6ce65a3b + workspace/nav/admin/services: 8ea10b45b9abab2a3bfc3c07e1c9cdc6 + workspace/nav/admin/integrations: 0ccce343287704cd90150c32e2fcad36 + workspace/nav/admin/registration: 594b4f1b4ec38b8083844b708854c0b1 + workspace/nav/systemAdmin: 9dc41f17bb1218a3001a67985f6e1669 + workspace/defaults/newWorkspaceName: 1044efda578897a57858e2d1380cf4f4 + workspace/defaults/defaultLayoutName: 27d88ef57309f741a9cf93e7b50aadea + workspace/defaults/defaultWorkflowDescription: 592d12540bb2f5c8ef18e0f9031875d1 + workspace/naming/workspacePrefix: b63ef0e99ee6f7fef6cbe4971ca6cf0f + workspace/naming/folderPrefix: 55abc55b8ff7891f38db1a5b7bf97765 + workspace/naming/subfolderPrefix: 0acbd49de7496480c62aaa7cb1b6cad8 + workspace/dashboard/title: c9380ea68c8c76ea451bd9613329a07c + workspace/dashboard/searchPlaceholder: 5e953115252eeb6785e4376e51960467 + workspace/dashboard/sections/workspaces: 8ba082a84aa35cf851af1cf874b853e2 + workspace/dashboard/sections/knowledgeBases: 8d99a6f68cba771817dcdc8f0fb56024 + workspace/dashboard/sections/pages: 75088c94fb3c374efd452624d4b74be8 + workspace/dashboard/sections/docs: 55fea190a0c0fb32acd3dc986cd3cc91 + workspace/dashboard/emptySearch: 61b6298e8425373029b9ec98959510ea + workspace/dashboard/pages/logs: cc9e9e82182a1b8173d7cf321b811683 + workspace/dashboard/pages/knowledge: 434d6cd2ab58b3fcfca0d49e0b354c8b + workspace/dashboard/pages/templates: 8ed62f56c81fa96c0f3182acb331bf63 + workspace/dashboard/pages/docs: 55fea190a0c0fb32acd3dc986cd3cc91 + workspace/knowledge/title: 434d6cd2ab58b3fcfca0d49e0b354c8b + workspace/knowledge/searchPlaceholder: 1871b5261b6bbd8e97b87979692185fd + workspace/knowledge/sort/lastUpdated: 956353f0af47fe60f0f45ec25d129a01 + workspace/knowledge/sort/newestFirst: cf6d84bb052305753b0b295a6abe5dcd + workspace/knowledge/sort/oldestFirst: 85b4e5a9899d16d0726139d7391d6921 + workspace/knowledge/sort/nameAsc: fb19f14e55fb3674e2a4ae677c68bc9d + workspace/knowledge/sort/nameDesc: 9f9be71499c8edfa16cadb5aa21190e0 + workspace/knowledge/sort/mostDocuments: c744002ffd6e746f78def6ebdda4b661 + workspace/knowledge/sort/leastDocuments: 2c3c820948899d2a3b7a4c90e530c224 + workspace/knowledge/actions/create: 757ccd28dd533ff3a933355273c1e32a + workspace/knowledge/actions/createTooltip: a5ce5913a61fa4c06d782acb5db878d7 + workspace/knowledge/errors/load: a4e2ca04aea47f7f4e5dde8a915f4933 + workspace/knowledge/errors/retry: 33dd8820e743e35a66e6977f69e9d3b5 + workspace/knowledge/emptyState/createFirst: 17b20f41a1d608aae3043d780c9c85fd + workspace/knowledge/emptyState/withEditPermission: 223b6c2d8cdf17d28e9deed34aec24b6 + workspace/knowledge/emptyState/withoutEditPermission: a1139e8f68ae0f127b1d52c6fcfa29a1 + workspace/knowledge/emptyState/buttonCreate: caf614c0722c3597d823241d278c69e0 + workspace/knowledge/emptyState/buttonContactAdmin: 7302a0e0d7e3b4d1c24410c8912a5f28 + workspace/knowledge/emptyState/noMatches: 1a97803845bffb95a53a8071af342b1f + workspace/logs/title/logs: cc9e9e82182a1b8173d7cf321b811683 + workspace/logs/title/monitors: dc6ee9eab7fe3a5a94e05ee656c02e2f + workspace/logs/title/dashboard: c9380ea68c8c76ea451bd9613329a07c + workspace/logs/searchPlaceholder: 319e35472e3815e114c635fd13d6097f + workspace/logs/live: 77a9c14fc9762e0e058e9b5ae04c8d15 + workspace/logs/monitorRequirement: 7e48195824435d324a9a319ef412c36e + workspace/logs/actions/addMonitor: 4ac1e72921050e085328dae83c2c7fc4 + workspace/logs/actions/refresh: c0aec3f31be4c984bae9a482572d2857 + workspace/logs/actions/refreshing: e7c1e6116dfa03055a93512263f347bb + workspace/logs/actions/exportCsv: 45e3c7a805c43d7142192a7cdc0135c9 + workspace/logs/errors/fetchLogs: 6102ff8b69ae08f2be6d09df11acc422 + workspace/logs/dashboard/title: c9380ea68c8c76ea451bd9613329a07c + workspace/logs/dashboard/searchPlaceholder: 38aedad9fc718854b020e915c46f165c + workspace/logs/dashboard/failedToFetchExecutionHistory: 1c0a7bc11a2290458a9a1b82d609ecab + workspace/logs/dashboard/loadingExecutionHistory: 008addd50aba959fc065690fe23ff07b + workspace/logs/dashboard/errorLoadingData: 2db37af3b34279bdf6421c43f78bc733 + workspace/logs/dashboard/noExecutionHistory: f469feef75ee7d2731aa1cfe8e506d5f + workspace/logs/dashboard/noExecutionHistoryDescription: fefcd3b54d74d772bf80ce4cf35e2009 + workspace/logs/dashboard/refresh: c0aec3f31be4c984bae9a482572d2857 + workspace/logs/dashboard/refreshing: e7c1e6116dfa03055a93512263f347bb + workspace/logs/dashboard/chart/noData: 38bb2fa5cbbe8eedf6d3a79f739ccf20 + workspace/logs/dashboard/chart/toggleSeries: 28026e34fcac980b440793dbe2eb2a7e + workspace/logs/dashboard/filters/title: acf5accc113ff3c1992688058576732c + workspace/logs/dashboard/filters/activeFilters: 5e8ba09b9ea1f87584d4f4ec6206ec7f + workspace/logs/dashboard/filters/clearAll: 854be0c051e4a3491a2cdd9dd8c1b4d5 + workspace/logs/dashboard/filters/suggestedFilters: 9e820f4ae013f5f889889c26bd929679 + workspace/logs/dashboard/filters/textSearch: 5352c7ef46dd9c42aede42c362515241 + workspace/logs/dashboard/filters/searchPlaceholder: 319e35472e3815e114c635fd13d6097f + workspace/logs/dashboard/filters/filterOptionsPlaceholder: dc32a54bef30e53685e15d222ecfe361 + workspace/logs/dashboard/filters/searchWorkflows: 38aedad9fc718854b020e915c46f165c + workspace/logs/dashboard/filters/searchFolders: 7e4a4ff7341db157966d92f49f8a9c3c + workspace/logs/dashboard/filters/searchOptions: b98a221ef01284460d0f7c31488fd891 + workspace/logs/dashboard/filters/loadingWorkflows: 8dd5796a8e3cf4dd5b18f66628c9ce31 + workspace/logs/dashboard/filters/loadingFolders: 373ec1c18aded3979aaae3a4e461cec0 + workspace/logs/dashboard/filters/noWorkflows: 3063988aac5626562268cae7f8ce41bc + workspace/logs/dashboard/filters/noFolders: 63b6490a020b322325052059141c7f6a + workspace/logs/dashboard/filters/noOptions: c115af64fc2104f6a26971a0c5491944 + workspace/logs/dashboard/filters/allWorkflows: 3adaa4f8f91c3e477f903bd1ad6a4481 + workspace/logs/dashboard/filters/selectedWorkflows: 121c9c9f9d071a65446cda46d2bb1792 + workspace/logs/dashboard/filters/allFolders: cff7bb4ed651d5c59916dccb1d4c0ab7 + workspace/logs/dashboard/filters/selectedFolders: 5d97cfe0674b81c38b6ca5f61e3cd326 + workspace/logs/dashboard/filters/allTriggers: 514e98193c0ea17d10e9cb2570f65e28 + workspace/logs/dashboard/filters/selectedTriggers: 6ef85108ee5407a7276171b55b6e1fed + workspace/logs/dashboard/filters/allTime: 62258944e7c2e83f3ebf69074b2c2156 + workspace/logs/dashboard/filters/past30Minutes: 94ef976e663730a936aa57e91e97a564 + workspace/logs/dashboard/filters/pastHour: 111f09af1de9c0b269751433a95157e7 + workspace/logs/dashboard/filters/past6Hours: 52a33655bed169916d2370fcd8276b57 + workspace/logs/dashboard/filters/past12Hours: 465017d851d9681bf92f1c4b69537a5a + workspace/logs/dashboard/filters/past24Hours: e3b9f5bb3d29412d320b7665e74603de + workspace/logs/dashboard/filters/past3Days: 1ccd94e3e2c6b5c533dd4cf67de50c7b + workspace/logs/dashboard/filters/past7Days: 2ec651d9372762ade4c8bc7aafab3030 + workspace/logs/dashboard/filters/past14Days: eb8f6d0fdb17b346ab223b593fd5f11c + workspace/logs/dashboard/filters/past30Days: 6d69a6c6519721c00a9ef320e70099bc + workspace/logs/dashboard/filters/manual: 772ff39554ea76c8db86f09b7d96fcb9 + workspace/logs/dashboard/filters/api: 01d9819514e27056dcc69463194b63d2 + workspace/logs/dashboard/filters/webhook: 70f95b2c27f2c3840b500fcaf79ee83c + workspace/logs/dashboard/filters/schedule: a75428cb811bc50150cecde090a3a0d5 + workspace/logs/dashboard/filters/chat: 1171b63c16b3431dca319af1804de2a0 + workspace/logs/dashboard/filters/error: 3c95bcb32c2104b99a46f5b3dd015248 + workspace/logs/dashboard/filters/info: 5a6cc405211f675152b2460292fe0fb9 + workspace/logs/dashboard/filters/anyStatus: 5be4cdef7d117d90c6a4ac626508f343 + workspace/logs/dashboard/filters/level: 1e3bf31d5c6083e898dd3e3359fbb0c2 + workspace/logs/dashboard/filters/workflow: 0240772c838117f1c837f4913fce9384 + workspace/logs/dashboard/filters/folder: 55abc55b8ff7891f38db1a5b7bf97765 + workspace/logs/dashboard/filters/trigger: 25f7594d1ac2f32a3d2774dcd11dddfe + workspace/logs/dashboard/filters/timeline: 342b1e646f0634e47112092db1a24f49 + workspace/logs/dashboard/filters/retentionPolicy: 3396d78131c3151afbc6f616ddde04ba + workspace/logs/dashboard/filters/retentionDescription: 5175b0f120505141600dc5b85a524a2a + workspace/logs/dashboard/filters/upgradePlan: bee64a612e779a8ae5df49da9555ef31 + workspace/logs/dashboard/metrics/totalExecutions: 102e0e9aefb8d9084a0f737872130400 + workspace/logs/dashboard/metrics/successRate: 914b4f146384a0699ed4853e22951015 + workspace/logs/dashboard/metrics/failedExecutions: 11a5105696bb9934ab37a2f7779580ca + workspace/logs/dashboard/metrics/activeWorkflows: f10fc4226b9da3ee8318a61380c2b264 + workspace/logs/dashboard/workflows/title: b0c9c8615a9ba7d9cb73e767290a7f72 + workspace/logs/dashboard/workflows/legend: a4f298c57d53720324a555055b8126e2 + workspace/logs/dashboard/workflows/count: 70182da0a175218aa5f02283b6f4ee62 + workspace/logs/dashboard/workflows/countPlural: 756307900733b239eedad5620614cafc + workspace/logs/dashboard/workflows/filteredFrom: 9af5265b3af15b846f6eebd6fb4940b0 + workspace/logs/dashboard/workflows/noMatches: 1edb6be0f5c001bcc44f7f768a42025f + workspace/logs/dashboard/workflows/selectedSegment: a58c83f2d5ae62280b75e1df1ff2d300 + workspace/logs/dashboard/workflows/filteredTo: d841bab99ff220aa3e6c379b51ad8dd2 + workspace/logs/dashboard/workflows/selectedRangeMore: 2a3a040188f3279bab3add7246dab177 + workspace/logs/dashboard/workflows/selectedRangeExecutions: 711e14bbd13492820e4213f0766f502b + workspace/logs/dashboard/workflows/clearFilter: d3b9ef599337436d6f38e146669f3cb8 + workspace/logs/dashboard/workflows/executions: c4d677326dc1125571a8a42a4984d82a + workspace/logs/dashboard/workflows/success: c43827becada6750f7a25890905f38b9 + workspace/logs/dashboard/workflows/failures: 0b61ef3d217891c09c0053b42d13bd41 + workspace/logs/dashboard/workflows/errorRate: 760fda382f976a6a4950795cb3dc4124 + workspace/logs/dashboard/workflows/duration: bf6ed0974fdd4eb9d8bd3f4f8c4bd5ae + workspace/logs/dashboard/workflows/columns/time: b504a03d52e8001bfdc5cb6205364f42 + workspace/logs/dashboard/workflows/columns/status: 4e1fcce15854d824919b4a582c697c90 + workspace/logs/dashboard/workflows/columns/trigger: 25f7594d1ac2f32a3d2774dcd11dddfe + workspace/logs/dashboard/workflows/columns/cost: d86f5271aee004133929c2572bb036d9 + workspace/logs/dashboard/workflows/columns/workflow: 0240772c838117f1c837f4913fce9384 + workspace/logs/dashboard/workflows/columns/output: c1e7f08b62c52a91234e00b88f52acc2 + workspace/logs/dashboard/workflows/columns/duration: bf6ed0974fdd4eb9d8bd3f4f8c4bd5ae + workspace/logs/dashboard/workflows/noExecutions: af94ace60ae0462b6bb43c88d238a774 + workspace/logs/dashboard/workflows/loadingMore: 265b328c20eab0c766521e63b08a774d + workspace/logs/dashboard/workflows/scrollToLoadMore: 28564e33812da5a848bde50337de5407 + workspace/logs/dashboard/workflows/succeeded: b9862748a448d00cc8910e0ae571e228 + workspace/logs/dashboard/workflows/segment: 04cc48c4a28bb0dd9ffca3357f56adb1 + workspace/logs/dashboard/workflows/allWorkflows: 3adaa4f8f91c3e477f903bd1ad6a4481 + workspace/logs/dashboard/workflows/multipleSelected: 56cfd60a0955cd22fa54ce5791bf13a8 + workspace/logs/dashboard/workflows/durationDay: a28ce82f53ce18bb86f3fc623d2f3e85 + workspace/logs/dashboard/workflows/durationHour: 5fb45d708b5f1bcaf2d79e1579889478 + workspace/logs/dashboard/workflows/durationMinute: 78790d210f3cf5727f5ab6ae24bc52c7 + workspace/logs/list/headers/time: b504a03d52e8001bfdc5cb6205364f42 + workspace/logs/list/headers/status: 4e1fcce15854d824919b4a582c697c90 + workspace/logs/list/headers/workflow: 0240772c838117f1c837f4913fce9384 + workspace/logs/list/headers/cost: d86f5271aee004133929c2572bb036d9 + workspace/logs/list/headers/trigger: 25f7594d1ac2f32a3d2774dcd11dddfe + workspace/logs/list/headers/duration: bf6ed0974fdd4eb9d8bd3f4f8c4bd5ae + workspace/logs/list/loading: da85228e859b9ea06c8a25dd74dcd793 + workspace/logs/list/loadingMore: 265b328c20eab0c766521e63b08a774d + workspace/logs/list/scrollToLoadMore: 28564e33812da5a848bde50337de5407 + workspace/logs/list/noLogs: c8667f58ca7495f2e691b360677a220b + workspace/logs/list/unknownWorkflow: 466ef5b579d9501f362cf3a77ce32afb + workspace/logs/details/title: f17c469daa39ac90a231cb544b7fe583 + workspace/logs/details/previous: 1441f46a72bc3ce6cdf2fc4c2daa857a + workspace/logs/details/next: a2948f517f685665268453342c5a7fe5 + workspace/logs/details/close: 2c2e22f8424a1031de89063bd0022e16 + workspace/logs/details/selectLog: 196cc5b526b2ce347506c1db40240d51 + workspace/logs/details/timestamp: dca66baee66b56fa66e399798d9e4976 + workspace/logs/details/workflow: 0240772c838117f1c837f4913fce9384 + workspace/logs/details/executionId: ff0118a1e8d28c6c9f83769aa04dd427 + workspace/logs/details/level: 1e3bf31d5c6083e898dd3e3359fbb0c2 + workspace/logs/details/trigger: 25f7594d1ac2f32a3d2774dcd11dddfe + workspace/logs/details/duration: bf6ed0974fdd4eb9d8bd3f4f8c4bd5ae + workspace/logs/details/loading: b0733758eef0b511da280a609911067f + workspace/logs/details/workflowState: 5170ea4ffc620a8328164c932bb35189 + workspace/logs/details/viewSnapshot: 2fa1281891582e23e6e77f34e4171f7f + workspace/logs/details/toolCalls: 4ff0b60636fd8efc16cbea2778761840 + workspace/logs/details/files: aee803d2f576ac1200c73b691191a916 + workspace/logs/details/costBreakdown: 155833e9a8598bed03da914554c71936 + workspace/logs/details/baseExecution: d69804b4940dbb04c77f3fada5d8f4ea + workspace/logs/details/modelInput: ff775939adbe30797616c96d9441fd47 + workspace/logs/details/modelOutput: a2f43b62b49c198abdfbcb7f2416dede + workspace/logs/details/total: f88aa2f74615d4c75984e36154b9a6e9 + workspace/logs/details/tokens: 3d283cd9582bb40f48788b8d1b770499 + workspace/logs/details/modelBreakdown: 0b6fbd68eb91c60063508b92a7a24f06 + workspace/logs/details/input: 64d8106e5094967936762c631af04515 + workspace/logs/details/output: 6a91718d5c0fb12b0f7069c337528c09 + workspace/logs/details/totalCostNote: f23ded5ea9bfde2eec42edb7fcb19977 + workspace/logs/details/unknownSize: 1e2383b1c4092c570edebd68ed00270b + workspace/logs/details/unknownType: 8fbd1d2887bd34356fdcf33d915df1bd + workspace/logs/details/unknownWorkflow: 5cd12b882fe90320f93130c1b50e2e32 + workspace/logs/details/unknownLevel: 09933be7a452b4ed5be01a3b2d30911a + workspace/logs/details/unknownValue: 5cd12b882fe90320f93130c1b50e2e32 + workspace/logs/details/traceSpans/workflowExecution: a2d2959ffa91134982d6636698ca38d1 + workspace/logs/details/traceSpans/collapseAll: 7913f9a07410d09e5462eae278305f05 + workspace/logs/details/traceSpans/expandAll: 98ea737f9501249c3a39d7d75563ff89 + workspace/logs/details/traceSpans/collapse: d64b33ae34d2ff729b0232220dbd9a99 + workspace/logs/details/traceSpans/expand: 1bffd02092bb2dd1ac0455fe93ceb1f2 + workspace/logs/details/traceSpans/noTraceData: 1430d5bbfa84b184cd859446f2e300e2 + workspace/logs/details/traceSpans/model: ded909b246142d37d4fdc47ae2eadb3b + workspace/logs/details/traceSpans/loadSkill: 253d48d6b6bbabd09cfb77dd9a7625cd + workspace/logs/details/traceSpans/initialResponse: 5acff2713f38746ac1e4601a9687cb7d + workspace/logs/details/traceSpans/modelResponse: 54cc43566afe22df74a9d697fcd0a294 + workspace/logs/details/traceSpans/modelGeneration: 770c3d94933a7fad1a996d63d0692c7c + workspace/logs/details/traceSpans/tokens: e47dfaa600f64a96431eeff2a85e90d3 + workspace/logs/details/traceSpans/tokensUnavailable: 372fe7ebf8d8c695f61bfdf2c0dea82c + workspace/logs/details/traceSpans/tokensInOut: f40cf23fa823d432bd54b4c0ddd2d3ef + workspace/logs/details/traceSpans/tokensTotal: 47a6ad9b70e54777b925e9621c2d672d + workspace/logs/details/traceSpans/tokensTotalSuffix: 8f47bea598a6496972b05a12fe993ab1 + workspace/logs/details/traceSpans/input: c281c28cbb062bc3538cbd4a42d79cf6 + workspace/logs/details/traceSpans/output: c1e7f08b62c52a91234e00b88f52acc2 + workspace/logs/details/traceSpans/total: f60dc0a14e9b1bace656c644de25de5b + workspace/logs/details/traceSpans/start: dbe56303d9a6d3fad1a8d4cbcc97f365 + workspace/logs/details/traceSpans/plusMs: 5e4f921070e4aa03356a02fa48a0008b + workspace/logs/details/traceSpans/betweenBlocks: 051a24546f3c8d89ef2509d3c3480ed3 + workspace/logs/details/traceSpans/inputSection: c281c28cbb062bc3538cbd4a42d79cf6 + workspace/logs/details/traceSpans/outputSection: c1e7f08b62c52a91234e00b88f52acc2 + workspace/logs/details/traceSpans/errorSection: 3c95bcb32c2104b99a46f5b3dd015248 + workspace/logs/details/traceSpans/segmentTimingTooltip: ffcfca7540ca6f0b4f0d4b28c7d4fa51 + workspace/logs/details/download/downloading: 9574740e8c9b05d3f3ff506e776da090 + workspace/logs/details/download/download: 56b7d0834952b39ee394b44bd8179178 + workspace/logs/monitors/loading: 4ec723493caca0224090979a93e37c88 + workspace/logs/monitors/noConfigured: e2c34af67263e6bf5ff8482b70561d59 + workspace/logs/monitors/status: 4e1fcce15854d824919b4a582c697c90 + workspace/logs/monitors/provider: d7bb643922dba7960bfb99ec1d746bdd + workspace/logs/monitors/auth: 424e3635a6e3d9574f023c10cd5e6f62 + workspace/logs/monitors/listing: 33e04d58d3fe9dab6b3d0ddbeb105a58 + workspace/logs/monitors/indicator: a9fe0ddef53545d72f40a2b0bef23288 + workspace/logs/monitors/workflow: 0240772c838117f1c837f4913fce9384 + workspace/logs/monitors/actions: c46571856723b03262fd33f511116298 + workspace/logs/monitors/active: 3e1ec025c4a50830bbb9ad57a176630a + workspace/logs/monitors/paused: edb1f7b7219e1c9b7aa67159090d6991 + workspace/logs/monitors/configured: 3c0671feaabf660951709ea3f7a72a5a + workspace/logs/monitors/missing: 25693c8cf26d8a4ab27608256ba12feb + workspace/logs/monitors/edit: eee7f39ff90b18852afc1671f21fbaa9 + workspace/logs/monitors/pause: 2d5bb31f91a5dedd5d24367ad5315a8e + workspace/logs/monitors/activate: cbe3629cad32c63dd160e5250f8a283f + workspace/logs/monitors/remove: dba2fe5fe9f83f8078c687f28cba4b52 + workspace/logs/monitors/searchProviders: 952372d409198fa8a958b18fdd178eec + workspace/logs/monitors/noProviders: 9271a9c1568c8b1919c25415b182a631 + workspace/logs/monitors/searchOptions: b98a221ef01284460d0f7c31488fd891 + workspace/logs/monitors/noOptions: c115af64fc2104f6a26971a0c5491944 + workspace/logs/monitors/searchIntervals: b691c104e1b9ba6c0dc61bedda47df9d + workspace/logs/monitors/noIntervals: 365869395ae7d5aa1c9ae4c45981915b + workspace/logs/monitors/searchWorkflows: 38aedad9fc718854b020e915c46f165c + workspace/logs/monitors/noWorkflows: 3063988aac5626562268cae7f8ce41bc + workspace/logs/monitors/searchIndicators: 1533dcb1cf16918b54c4fe2941b1b6a7 + workspace/logs/monitors/noIndicators: 70fb7787461f501376d7884dd99b79b3 + workspace/logs/monitors/loadRequirements: 38c66616cb07643e9b57d88278257d3b + workspace/logs/monitors/noDeployedWorkflow: 931dbdeab7a84632b44348239200e426 + workspace/logs/monitors/failedToLoad: e9c4292e9ab29edba9fd5eba4c700057 + workspace/logs/monitors/failedToFetchLogs: a125843e62dbda3780db317ce66aba7d + workspace/logs/monitors/failedToSave: 2332263d4f2a502fb04bee5918195948 + workspace/logs/monitors/activateDisabled: 1a72f58e9f58ae3fb6d1655b211c0447 + workspace/logs/monitors/failedToUpdateState: a9acf453979c3c4b32fb76ce28890684 + workspace/logs/monitors/failedToDelete: 4f3b32392a702537f9e4406e1c6f6561 + workspace/logs/editor/provider: d7bb643922dba7960bfb99ec1d746bdd + workspace/logs/editor/auth: 424e3635a6e3d9574f023c10cd5e6f62 + workspace/logs/editor/listing: 33e04d58d3fe9dab6b3d0ddbeb105a58 + workspace/logs/editor/interval: 5af7bb003b4c592d32e7755cd72ddabf + workspace/logs/editor/workflow: 0240772c838117f1c837f4913fce9384 + workspace/logs/editor/indicator: a9fe0ddef53545d72f40a2b0bef23288 + workspace/logs/editor/description: 1cedf68d621e93bfba1c7b0a37d40ff8 + workspace/logs/editor/feed: c43580f7c529b87a9339a169b66e3f7d + workspace/logs/editor/selectInterval: 660c9a0487ad053f62e25aada5ebdab3 + workspace/logs/editor/selectWorkflow: 6b2e20487ee117f26887ecd7784a617f + workspace/logs/editor/selectIndicator: 5228ade403b12fc6a3e6862b683d4d61 + workspace/logs/editor/save: d895276cde226e9225eca1e74aa799f4 + workspace/logs/editor/create: 1a78040e4c8f811e25c339f18a7b019c + workspace/logs/editor/saving: 7cb2d8f012d57db8a62187b65ef164dc + workspace/logs/editor/error: 3c95bcb32c2104b99a46f5b3dd015248 + workspace/logs/editor/cancel: 2e2a849c2223911717de8caa2c71bade + workspace/logs/editor/createTitle: 1a78040e4c8f811e25c339f18a7b019c + workspace/logs/editor/editTitle: 88602240cefb64a9692ef1d60fc8621e + workspace/logs/editor/selectProvider: b1e63121f1345cc3885aad6f68b6d9d5 + workspace/logs/editor/searchProviders: 952372d409198fa8a958b18fdd178eec + workspace/logs/editor/searchOptions: b98a221ef01284460d0f7c31488fd891 + workspace/logs/editor/searchIntervals: b691c104e1b9ba6c0dc61bedda47df9d + workspace/logs/editor/searchWorkflows: 38aedad9fc718854b020e915c46f165c + workspace/logs/editor/searchIndicators: 1533dcb1cf16918b54c4fe2941b1b6a7 + workspace/logs/editor/noProviders: 9271a9c1568c8b1919c25415b182a631 + workspace/logs/editor/noOptions: c115af64fc2104f6a26971a0c5491944 + workspace/logs/editor/noIntervals: 365869395ae7d5aa1c9ae4c45981915b + workspace/logs/editor/noWorkflows: 3063988aac5626562268cae7f8ce41bc + workspace/logs/editor/noIndicators: 70fb7787461f501376d7884dd99b79b3 + workspace/logs/editor/authConfigured: 3c0671feaabf660951709ea3f7a72a5a + workspace/logs/editor/authMissing: 25693c8cf26d8a4ab27608256ba12feb + workspace/environment/title: 538ccbbb5bae1e2b53c526e8e06feef3 + workspace/environment/searchPlaceholder: 27e69e119f349ef40dca0cd0374d96c0 + workspace/environment/scope/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f + workspace/environment/scope/personal: 1c619008563115c5c99acdab4e8c9717 + workspace/environment/create/workspace: 7248e3ee7bf502aecc934cd6a4d2c42f + workspace/environment/create/personal: 870fbece6d24ecc796ca7245f9027f23 + workspace/environment/emptyState/workspace/title: 1be578191ecad35c8b3c9bc47519e731 + workspace/environment/emptyState/workspace/description: 50a228b7542cb5751970238b74f03337 + workspace/environment/emptyState/personal/title: b58ae562936547007f62289e3eb2b7d8 + workspace/environment/emptyState/personal/description: 50a228b7542cb5751970238b74f03337 + workspace/environment/searchEmpty/workspace: 38af747ebe20e0f0d3c856278fb92db0 + workspace/environment/searchEmpty/personal: 57499cb4e058eda280ec523184fc7c78 + workspace/environment/headers/createdAt: 9ce495d7fc74e1a2ae86c07206a3e531 + workspace/environment/headers/variable: c13db5775ba9791b1522cc55c9c7acce + workspace/environment/headers/value: 34b0eaa85808b15cbc4be94c64d0146b + workspace/environment/headers/updatedAt: a3730393cce5adfd9e50123d96640fd6 + workspace/environment/headers/actions: c46571856723b03262fd33f511116298 + workspace/environment/labels/untitledVariable: a38aecd1d8e912f22cb8727cc8fc000b + workspace/environment/labels/overriddenByWorkspaceVariable: 4b4ff2956d39d30bf03dbbad4ac520bf + workspace/environment/labels/revealValue: 24fd35cc30bb5db953bdc7580713453e + workspace/environment/labels/hideValue: 3252467bb79f85547ee0b2ed851aeb05 + workspace/environment/labels/copyValue: 5b6d813bd63742cd9d2b52e6c82c7313 + workspace/environment/labels/save: aaaf92ee15365627522c70c070744491 + workspace/environment/labels/cancel: 392d33da649aeb10ede0af940d3ffd97 + workspace/environment/labels/edit: b9d723fde6af425b442e98f67c10a9a1 + workspace/environment/labels/delete: b357343fb277c637c176318eef3e3c3e + workspace/apiKeys/title: f961b547cd312cc8b9b79f0c9e0b2cc3 + workspace/apiKeys/cardTitle: c6ed5d8084b73834252badf2763cffaa + workspace/apiKeys/searchPlaceholder: 29dc67aa18667d38a9bd40d49c21146d + workspace/apiKeys/scope/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f + workspace/apiKeys/scope/personal: 1c619008563115c5c99acdab4e8c9717 + workspace/apiKeys/create/workspace: d99f60f915399a4fe978056bc1f5cf81 + workspace/apiKeys/create/personal: d57d09ad99a1ffdbb305faed0455bc35 + workspace/apiKeys/emptyState/workspace/title: 2d23ddda6183d0bccfc1c11084ecd47d + workspace/apiKeys/emptyState/workspace/description: 12464030387a03da4934ad596546343a + workspace/apiKeys/emptyState/workspace/button: 0d385c354af8963acbe35cd646710f86 + workspace/apiKeys/emptyState/personal/title: de6a6d32ab13e5058c8f22a5d580e72a + workspace/apiKeys/emptyState/personal/description: 12464030387a03da4934ad596546343a + workspace/apiKeys/emptyState/personal/button: 0d385c354af8963acbe35cd646710f86 + workspace/apiKeys/searchEmpty: eb6c7ca2c1a7d50a35d3ec55e5ec84a9 + workspace/apiKeys/headers/createdAt: 9ce495d7fc74e1a2ae86c07206a3e531 + workspace/apiKeys/headers/name: 9368b5a047572b6051f334af5aa76819 + workspace/apiKeys/headers/key: 3d1065ab98a1c2f1210507fd5c7bf515 + workspace/apiKeys/headers/lastUpdate: 25c7121e9b5d99e12ca82f2ae4a47424 + workspace/apiKeys/headers/actions: c46571856723b03262fd33f511116298 + workspace/apiKeys/labels/never: 975007d8aae7e3af47897721a6961be8 + workspace/apiKeys/labels/lastUsed: 683d1c2441b1368ca171c89a672c4676 + workspace/apiKeys/labels/saveName: cc039e6fe2836f8ae4ae320f64c61c75 + workspace/apiKeys/labels/rename: d91c6ab96854d68b461a1aa6f2e42cee + workspace/apiKeys/labels/reveal: 60907a04fba69c285468b6eef9b42311 + workspace/apiKeys/labels/hide: 23e71450ee75ac29bbd75eef5702d70a + workspace/apiKeys/labels/copy: d1efd63a83c4d57a913ca5b919ad8e44 + workspace/apiKeys/labels/save: 00569a8e15976bf3a95cffb1f80217e6 + workspace/apiKeys/labels/cancelRename: 1a9f15503b6d469426d3aff8dc196cbf + workspace/apiKeys/labels/delete: 7d899437bec94521f059b81ec1fb3893 + workspace/apiKeys/labels/nameRequired: 9cfb07618c3bfb2d8d50f93c983e9ae6 + workspace/apiKeys/labels/duplicateName: 61d8f017189a150f2736abc77b9fda1c + workspace/apiKeys/labels/failedRename: 5ca80dbdbbe3af03f02c6f6582e96e7e + workspace/apiKeys/labels/unableRename: db93096a10af2910564feee895b05ce2 + workspace/apiKeys/labels/failedCreate: f2ce5a3cae23183afa674465ae60ba4e + workspace/apiKeys/labels/workspaceAccess: 56a61a4e953feb7f8b655f36f1cfc436 + workspace/apiKeys/labels/personalAccess: 0e052bce07ea25e44d05eb79bb0fef4a + workspace/apiKeys/labels/onlyTimeYouWillSee: 370e79da215fa69254d7e66638aa98f1 + workspace/apiKeys/labels/unableToDetermineWorkspace: c1d948702abb0a9b8a45bb6ebd68f334 + workspace/apiKeys/labels/workspacePermissions: 0931f79ddd50a2aeda3807d865f267d8 + workspace/apiKeys/dialogs/createTitle: 2b4762a0048b0ab2130236e55998b82b + workspace/apiKeys/dialogs/createNameLabel: 9368b5a047572b6051f334af5aa76819 + workspace/apiKeys/dialogs/createNamePlaceholder: 4b1093d395860389f1255e7f71af3f45 + workspace/apiKeys/dialogs/createButton: 0d385c354af8963acbe35cd646710f86 + workspace/apiKeys/dialogs/newKeyTitle: 8a8ff9cbd0170203a7c935d028b5dcfb + workspace/apiKeys/dialogs/newKeyDescription: 370e79da215fa69254d7e66638aa98f1 + workspace/apiKeys/dialogs/deleteTitle: bd83fa62db3811554410d53b2647e878 + workspace/apiKeys/dialogs/deleteDescription: 776a78605dd2d138c76d0761b8c794b3 + workspace/apiKeys/dialogs/deletePrompt: 41a5e7f1bd6be2dda0806b6d6b2f23f8 + workspace/apiKeys/dialogs/deletePlaceholder: 2d8aeb08b2cce3b750a584bbc5ce6d1d + workspace/apiKeys/dialogs/cancel: 2e2a849c2223911717de8caa2c71bade + workspace/apiKeys/dialogs/deleteButton: ac84e5806c21fac4cc486dfa0d68fde9 + workspace/apiKeys/dialogs/copyToClipboard: 8992c838874db83e3cc89c9fb4442bfd + workspace/integrations/title: 0ccce343287704cd90150c32e2fcad36 + workspace/integrations/searchPlaceholder: e7c7de623915031f31563635f2a03842 + workspace/integrations/successMessage: 432417fdba40ca79446fcd599dec2952 + workspace/integrations/actionRequired/title: 50994325ec2223262fa572814dc49ab5 + workspace/integrations/actionRequired/description: 7810609dc8da41fc934bec01e938a3d6 + workspace/integrations/actionRequired/button: ac5fca3b98de0653853e81aefe8a626f + workspace/integrations/otherServices: 05eeab7caa5bc3eb5a6d7a2d555a61e8 + workspace/integrations/connect: 8778ee245078a8be4a2ce855c8c56edc + workspace/integrations/disconnect: 8cd3daae20ec3d3bf09b25881caa2b8f + workspace/integrations/emptyState/noConnectible: 30a8734e2233b3e8cbe697bcaaea3f77 + workspace/integrations/emptyState/noSearchMatches: 2189e7110420c45f31f1d9724d14ea3e + workspace/integrations/errors/loadAvailability: d316674395c7fde0804b761debb016aa + workspace/integrations/errors/oauth: 24abde3bc9e543535f36bbef81dffeff + workspace/files/title: aee803d2f576ac1200c73b691191a916 + workspace/files/searchPlaceholder: f7d81f1b6ba06b995636cc0f398681fd + workspace/files/upload/idle: 189b8bd320e87a7e14fbee06b013d90f + workspace/files/upload/uploading: baef62e2015a34d6747ed6e4192a27b1 + workspace/files/upload/uploadingWithCount: 7b0f554d1c3ff75afae9d85f57d6a0eb + workspace/files/upload/button: 189b8bd320e87a7e14fbee06b013d90f + workspace/files/headers/name: 9368b5a047572b6051f334af5aa76819 + workspace/files/headers/size: 227fadeeff951e041ff42031a11a4626 + workspace/files/headers/uploaded: cc04b77fa4bfd479fc80b05a769d49ce + workspace/files/headers/actions: c46571856723b03262fd33f511116298 + workspace/files/emptyState/title: 77c48c38a9c2251f5d1bafd9eff3d0a0 + workspace/files/emptyState/description: 16349f52a7ecb083bc6892257c853efd + workspace/files/emptyState/button: 189b8bd320e87a7e14fbee06b013d90f + workspace/files/searchEmpty/title: f48077c20082ecaa65a43fc1368f986a + workspace/files/searchEmpty/description: 590a2cb4d0bc574f78f9b48a94cad589 + workspace/files/actions/download: 56b7d0834952b39ee394b44bd8179178 + workspace/files/actions/delete: 8bcf303dd10a645b5baacb02b47d72c9 + workspace/files/deleteDialog/title: dc1a292fb2d3ef6299b97f9d95b14a19 + workspace/files/deleteDialog/descriptionWithName: 819d46f4232a96974dc61813e64c5a12 + workspace/files/deleteDialog/description: 01b77e31a20cc3a8f1d68e8865b2e31a + workspace/files/deleteDialog/warning: 3d8b13374ffd3cefc0f3f7ce077bd9c9 + workspace/files/deleteDialog/cancel: 2e2a849c2223911717de8caa2c71bade + workspace/files/deleteDialog/confirm: 8bcf303dd10a645b5baacb02b47d72c9 + workspace/files/deleteDialog/deleting: 683cd814a3fc94e50b3e4e4a9c90c19c + workspace/userMenu/accountDetail: 65606eb396225ca6c8239e08518679d0 + workspace/userMenu/helpSupport: 662d807b3768c9753238bd5f1c86d2f6 + workspace/userMenu/serviceApiKeys: 4e483ca8b6e6b430a41f7d1c4ebdef92 + workspace/userMenu/subscription: ba9f3675e18987d067d48533c8897343 + workspace/userMenu/manageBilling: f94ec8984a4c2cbc2f8523e12063e0c9 + workspace/userMenu/openingBilling: 7b73589be6b182ccd6c5320310994fd2 + workspace/userMenu/teamManagement: e801be1c8cb547c1d15f9a0d436355bc + workspace/userMenu/singleSignOn: b562d4c80c590e48d428cab93c2e69cd + workspace/userMenu/logOut: 9a236bb8f8bec867ec0d0950d38bcc71 + workspace/userMenu/loggingOut: 0440d18b48030a7b3c7d6d46d0edb7f6 + workspace/userMenu/billingPortalSelectOrganization: 1f8337c4aea583d71ab7d3612de5e5bf + workspace/userMenu/billingPortalFailed: 9d257887fd2ff1af3b6b192ca5061e3c + workspace/userMenu/themeLabel: 64be42f6f132f61a606ca9d53bddf6fe + workspace/userMenu/languageLabel: 277fd1a41cc237a437cd1d5e4a80463b + workspace/userMenu/themeOptions/light: bb78ea2c6edd8662676c81a8a13ecb93 + workspace/userMenu/themeOptions/system: 803281c327a02a2feba182f4a2b38311 + workspace/userMenu/themeOptions/dark: 73e6e208ba628b26e90fcf6dce15e1b2 + workspace/userMenu/defaultAvatarAlt: a84474aa50903fd90a3a39924e69b8b9 + workspace/settingsModal/titles/account: 90c84d04385b6a85b13a37a4d5357a2c + workspace/settingsModal/titles/service: 4e483ca8b6e6b430a41f7d1c4ebdef92 + workspace/settingsModal/titles/subscription: ba9f3675e18987d067d48533c8897343 + workspace/settingsModal/titles/team: e801be1c8cb547c1d15f9a0d436355bc + workspace/settingsModal/titles/sso: b562d4c80c590e48d428cab93c2e69cd + workspace/settingsModal/titles/help: 662d807b3768c9753238bd5f1c86d2f6 + workspace/settingsModal/common/cancel: 2e2a849c2223911717de8caa2c71bade + workspace/settingsModal/account/profilePicture: 982f611801ab04620bb2b4cc399d8dd3 + workspace/settingsModal/account/profilePictureAlt: d9b5e595877ffe2557486a987dd28274 + workspace/settingsModal/account/dropImage: 66644c6f24fbe45064e125ed4a11c507 + workspace/settingsModal/account/imageHint: fe6fbee20ab57e6334b161d93ef4a167 + workspace/settingsModal/account/profileDetails: 8834893e10b6da6b16301648c6345376 + workspace/settingsModal/account/profileDetailsDescription: 80955ddf08dac9841ccbd074214a8100 + workspace/settingsModal/account/fullName: f45991923345e8322c9ff8cd6b7e2b16 + workspace/settingsModal/account/emailAddress: 3ba3f099b1b9be6c35ad797da660cb9f + workspace/settingsModal/account/emailHint: edec8c6c3ea30c1861e93d938f9d89b2 + workspace/settingsModal/account/passwordReset: ef1a62d5f4330d3c0b72461108fbe9e4 + workspace/settingsModal/account/passwordResetDescription: 443cca03837cbe5d468ab59aa20adba7 + workspace/settingsModal/account/sendLink: c7ca704b7e1cac40862f9ee51d6d9aea + workspace/settingsModal/account/sending: 9b3372eb8ae59ba9b1e7044ece1f7645 + workspace/settingsModal/account/saveName: 7b186baba422fc0b558f5e93655eb342 + workspace/settingsModal/account/cancelEditingName: 53ddf531d739a0e424424656a5784e32 + workspace/settingsModal/account/editName: b981bbf3dcb8f8acde210cff1d8688b3 + workspace/settingsModal/account/privacy: 6007d5d5f6591c027b15bd49ab7a8c47 + workspace/settingsModal/account/privacyDescription: 9b6d63018003ff27f707262a2071bd85 + workspace/settingsModal/account/telemetry/label: 3b0cb6397936f3335f3651a64e145343 + workspace/settingsModal/account/telemetry/tooltipLabel: 6ed40bf1bf7d50b286add3f4df231e03 + workspace/settingsModal/account/telemetry/tooltipBody: cf4f7ca69e9557ead22c7faa307e7e20 + workspace/settingsModal/account/telemetry/body: b36843a447b06db09a731493daf8e93b + workspace/settingsModal/account/status/profileSaved: de9f3a096006f0d0a5ac63d582f9cb7e + workspace/settingsModal/account/status/nameRequired: 4830683dd37f0b692f9d9e8d89ef67b4 + workspace/settingsModal/account/status/saveError: 38d4e76523aa58a73c61ade19ecea240 + workspace/settingsModal/account/status/nameRequiredValidation: 9cfb07618c3bfb2d8d50f93c983e9ae6 + workspace/settingsModal/account/status/profilePictureUpdateError: 6f9933ae2903b5bdb2a93179026469b0 + workspace/settingsModal/account/status/profilePictureRemoveError: cd8f1549f5697ee34eefa35ad66247c8 + workspace/settingsModal/account/status/unableToUpdateProfilePicture: 8afebffb4b2aa80727d2f1e8f0517844 + workspace/settingsModal/account/status/failedUpdateName: 1f369ae0a3d278911a8c86782f141e2d + workspace/settingsModal/account/status/unableToUpdateName: 8a2b483aef55de74d8e762f292d2aef9 + workspace/settingsModal/account/status/noEmail: b1e1d3314408ad78a8a438c4708d16d5 + workspace/settingsModal/account/status/passwordResetSent: 99130a1589655ddadb714d432dfe7a4f + workspace/settingsModal/account/status/passwordResetFailed: 8598331a57a7bd6d14c4b52985cc4507 + workspace/settingsModal/help/requestType: f5d1e79cce69c212cbf3c472a0934fa1 + workspace/settingsModal/help/requestTypePlaceholder: 4b082c406411260e79b604d4499711b7 + workspace/settingsModal/help/requestTypes/bug: e558d1f100e21230c2f495a8913ac5ec + workspace/settingsModal/help/requestTypes/feedback: 6fac88806e0c269a30777b283988c61c + workspace/settingsModal/help/requestTypes/feature_request: c9de91b9a7020213381977f7d147064f + workspace/settingsModal/help/requestTypes/other: 79acaa6cd481262bea4e743a422529d2 + workspace/settingsModal/help/subject: de5b885eb327b2f233f3b67aab4c4c0a + workspace/settingsModal/help/subjectPlaceholder: 67d5f5a928fb8a17f61da58b19fa8922 + workspace/settingsModal/help/message: f2f72126bd244cfc534eab395e054362 + workspace/settingsModal/help/messagePlaceholder: d90ff067bfaafd24c6da8f395b991ff1 + workspace/settingsModal/help/attachments: 0a6841ff4b69df42293c377c60b4041e + workspace/settingsModal/help/dropImages: afbf3dcff028e25c29409e6615fa2e88 + workspace/settingsModal/help/dropImagesBrowse: 945866994ec8eccda8327b9086841b23 + workspace/settingsModal/help/imageHint: c73fd7ef0d3b6123a2ed4faea0f112f5 + workspace/settingsModal/help/uploadedImages: 6c8f1f24f527bacacbda8b22b2f3781d + workspace/settingsModal/help/cancel: 2e2a849c2223911717de8caa2c71bade + workspace/settingsModal/help/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e + workspace/settingsModal/help/submitting: 6845d212e994f3023c5287bdd3b74875 + workspace/settingsModal/help/processing: c3c727f3023486baeb73ad954d47c470 + workspace/settingsModal/help/success: c43827becada6750f7a25890905f38b9 + workspace/settingsModal/help/error: 3c95bcb32c2104b99a46f5b3dd015248 + workspace/settingsModal/help/errorMessages/subjectRequired: cbfc60ac4c7abbafbfec4b8805f28ff0 + workspace/settingsModal/help/errorMessages/messageRequired: d6846f9af19c26e9f650cf376fe01d55 + workspace/settingsModal/help/errorMessages/requestTypeRequired: 235972f5d769f0d502996ee25d0739fc + workspace/settingsModal/help/errorMessages/fileTooLarge: 1b8d2652c53c81e0c5bda83529d41714 + workspace/settingsModal/help/errorMessages/unsupportedFormat: 42baeb8323704b5495dcf2e92fbef3dc + workspace/settingsModal/help/errorMessages/processing: fa2f671153752cbbf3dddeb34ce9b764 + workspace/settingsModal/help/errorMessages/submitFailed: 13d3523331071e7410eaa2dbe68e4a95 + workspace/settingsModal/help/errorMessages/unknown: 4fa139b42a7b7ed262b07bdda7cd2fb6 + workspace/settingsModal/service/copilot/title: a912b3c7fb996fefccb182cf5c4a3fbc + workspace/settingsModal/service/copilot/description: 9d035940baffd8f924847b2a9656e70f + workspace/settingsModal/service/market/title: 7729913d1aff573d9d207509d5f41774 + workspace/settingsModal/service/market/description: 1c5e493f07b79a53689dc63faddc6039 + workspace/settingsModal/service/create: 757ccd28dd533ff3a933355273c1e32a + workspace/settingsModal/service/noKeys: 2e59427c4ac42b225db787fcfda348a6 + workspace/settingsModal/service/generateSuccessTitle: b102bb155e692fae272045784dbd13d6 + workspace/settingsModal/service/generateSuccessDescription: 10455d609f00fb27e34af788a2bf287f + workspace/settingsModal/service/copyToClipboard: 8992c838874db83e3cc89c9fb4442bfd + workspace/settingsModal/service/deleteTitle: 798d20836d1855aa00ab647e39b74cfa + workspace/settingsModal/service/deleteDescription: aba9826ee0c3649ef78efb7464cda826 + workspace/settingsModal/service/cancel: 2e2a849c2223911717de8caa2c71bade + workspace/settingsModal/service/delete: 8bcf303dd10a645b5baacb02b47d72c9 + workspace/settingsModal/subscription/titles/manage: 31cafd367fc70d656d8dd979d537dc96 + workspace/settingsModal/subscription/titles/restore: 7b1398331e9ea3559a47d95d4a0da3d2 + workspace/settingsModal/subscription/titles/upgrade: 63c3b52882e0d779859307d672c178c2 + workspace/settingsModal/subscription/titles/increaseLimit: c1c1b2f99de09d5268d9b53a84732571 + workspace/settingsModal/subscription/titles/nextBillingDate: 655fbadda932dde0b6518c74394380a1 + workspace/settingsModal/subscription/titles/usageNotifications: 7e2dfb13d4db318bd3dfc8e685f3a52c + workspace/settingsModal/subscription/titles/billingOwner: 999a31b813bb4bf665bc2dc011d19899 + workspace/settingsModal/subscription/titles/organizationUsage: 2b1f5f0c0ff3deb74a7e0fe14156dfae + workspace/settingsModal/subscription/titles/custom: b7b89901f46267f532600a23cfc54ae2 + workspace/settingsModal/subscription/titles/seats: 6e3f470ef5468c5195ae09f518b4dea4 + workspace/settingsModal/subscription/seatsText: e152e8bbf3719088301804c988cc99d0 + workspace/settingsModal/subscription/descriptions/manage: 07aa5067110fd7a965b73b42c4a39451 + workspace/settingsModal/subscription/descriptions/usageNotifications: 753dbaf7040cc2730726103d2994ee15 + workspace/settingsModal/subscription/descriptions/customPlan: b398e989c5849659f1031cad3bc6fb41 + workspace/settingsModal/subscription/descriptions/teamMemberView: 792ea5d1fa018695fe495b80ec70e084 + workspace/settingsModal/subscription/limit/save: 110f17c9323ca74a85d81e4103dfb529 + workspace/settingsModal/subscription/limit/edit: 860e7a09e2a29d47e092907405bf3116 + workspace/settingsModal/subscription/billingOwner/title: 999a31b813bb4bf665bc2dc011d19899 + workspace/settingsModal/subscription/billingOwner/description: b0e2b30555612befd6177a8174303a18 + workspace/settingsModal/subscription/billingOwner/error: 3c95bcb32c2104b99a46f5b3dd015248 + workspace/settingsModal/subscription/billingOwner/ownerLabel: 9c0b3aeca046c3ebd3bdc352edc83e88 + workspace/settingsModal/subscription/billingOwner/selectPlaceholder: 0f604b69c0dd00ca449328726cd7fd58 + workspace/settingsModal/subscription/billingOwner/organization: 3dc8489af7e74121f65ce6d9677bc94d + workspace/settingsModal/subscription/billingOwner/billingNotice: 8223fcdd72ae3a19b3ed3b9372ac58a9 + workspace/settingsModal/subscription/billingOwner/noActiveOrganization: 1740e7162f3366ace6e9a298c197fa22 + workspace/settingsModal/subscription/billingOwner/invalidSelection: e9ff790fd856257830d078aff582c8a1 + workspace/settingsModal/subscription/billingOwner/failedToUpdate: f27d74341ab928ef256ec6f01f4db7a6 + workspace/settingsModal/subscription/actions/manage: a3d40c0267b81ae53c9598eaeb05087d + workspace/settingsModal/subscription/actions/contact: 9afa39bc47019ee6dec6c74b6273967c + workspace/settingsModal/subscription/actions/upgradeTo: ea1a605ee4906ef1b8502ff0fb45de15 + workspace/settingsModal/subscription/badges/resolvePayment: 6ea59ba4c786c1d4390652dbb0c391f9 + workspace/settingsModal/subscription/badges/addPaymentMethod: 0d3902a770e7bfaf91d577cc4718d869 + workspace/settingsModal/subscription/badges/activatePayg: 355aa9b6a65788048c78f3f064017dfd + workspace/settingsModal/subscription/badges/increaseLimit: c1c1b2f99de09d5268d9b53a84732571 + workspace/settingsModal/subscription/badges/manageBilling: f94ec8984a4c2cbc2f8523e12063e0c9 + workspace/settingsModal/subscription/errors/openBillingPortal: 9d257887fd2ff1af3b6b192ca5061e3c + workspace/settingsModal/subscription/errors/unknown: d1282e5b894fc84e5edc4ae85e14d6e8 + workspace/settingsModal/subscription/errors/selectOrganization: 1f8337c4aea583d71ab7d3612de5e5bf + workspace/settingsModal/subscription/errors/activatePayg: 21b4223216c94fc40b69f05572c87fc5 + workspace/settingsModal/subscription/errors/loadBillingData: 8b0747d3173aa2c4ab9e30d34ac0d1fc + workspace/settingsModal/team/error: 3c95bcb32c2104b99a46f5b3dd015248 + workspace/settingsModal/team/defaultTeamName: d6d2ea34d1b3b9305f93b7c6a071507f + workspace/settingsModal/team/billingHowWorksSeatCost: 708119c843be5ddc0ee40894d8e95903 + workspace/settingsModal/team/billingHowWorksUsageTracked: a1c7eaba158113e15d78493293e28da7 + workspace/settingsModal/team/billingHowWorksIncreaseLimit: 5112e2758a3a22c3da5c099e25623e86 + workspace/settingsModal/team/billingHowWorksOverage: 126e0f5963375952da4a2f9c867386a4 + workspace/settingsModal/team/howBillingWorks: ad4d537c894aca89eb757d02f584a6b6 + workspace/settingsModal/team/usageNote: 7304c53ae53d3495ae3263838c1053a4 + workspace/settingsModal/team/usageNoteBody: 4ee25a7ce57671e8803ffc4664f439f6 + workspace/settingsModal/team/teamId: 99a9409917586091f0426b4025c70b74 + workspace/settingsModal/team/created: 27460d082f450ccbae82893390baf9fd + workspace/settingsModal/team/yourRole: 6b00b1cd11b02036cab2f680babbac4a + workspace/settingsModal/team/upgradeToCreateTeam: f3195867ae01aff87e33ecad5d9512ad + workspace/settingsModal/team/upgradeToCreateTeamDescription: 0ce31bf92b2c6e5c257e9e3cc40ff832 + workspace/settingsModal/team/openSubscriptionSettings: 941bb2af85619a9ffae73edd98e7ab16 + workspace/settingsModal/team/createYourTeamWorkspace: fb46d28d8d3569aed2936534a9313748 + workspace/settingsModal/team/createYourTeamWorkspaceDescription: e1268344efc7a0ceec218107fdb55a7b + workspace/settingsModal/team/teamName: d1a5f99dbf503ca53f06b3a98b511d02 + workspace/settingsModal/team/teamNamePlaceholder: 051e55d1b7df09db146c49ca7812e2b0 + workspace/settingsModal/team/teamUrl: 29e1dfe4aef6e2ad84f0828ad60b1d3b + workspace/settingsModal/team/teamSlugPlaceholder: 5ff5b1bd3bbde5e4268b72bd340885aa + workspace/settingsModal/team/createTeamWorkspace: a16ef45d4b54c13734364320842323e6 + workspace/settingsModal/team/noTeamSubscriptionFound: 5dd2b94643825ff9c4af7f36be0b6e1a + workspace/settingsModal/team/subscriptionMayNeedTransfer: 6c12add12e644d710196920f8eb06aed + workspace/settingsModal/team/setUpTeamSubscription: 477b34fbb2d697fd3552f7de0967bb89 + workspace/settingsModal/team/seats: 36a63aa896670d9626cfecd17536227d + workspace/settingsModal/team/pricePerSeat: 378f45f07d701c562bfa24bcdcfd0018 + workspace/settingsModal/team/used: f785e74a12d6ac21884de00cf34eafb7 + workspace/settingsModal/team/total: bbe803c749fae522bf928b3e07ccf6b5 + workspace/settingsModal/team/removeSeat: 71abe870af2f9ad40771a8abdba0d6be + workspace/settingsModal/team/addSeat: a599ed4f47246d0a0187d50a07850687 + workspace/settingsModal/team/seat: f4d7a0f3b80b9fc8f5a902bf7870425a + workspace/settingsModal/team/numberOfSeats: f451d684d81b2840d48521464b213dbe + workspace/settingsModal/team/yourTeamWillHave: bcd2e75dacb4435989e6699e652ccbf3 + workspace/settingsModal/team/minimumSeatsNoMax: c3299da9e26e431815c09089c9d96502 + workspace/settingsModal/team/chooseBetweenSeats: 3aa6855f9010b73ef51c2a68b21784d0 + workspace/settingsModal/team/currentSeats: 98c4907f8abb2392dea7cc4ec0279946 + workspace/settingsModal/team/newSeats: 60edb74a826b338444a7b667b8f7b7e1 + workspace/settingsModal/team/monthlyCostChange: f7debe8fdb2a57752327dbb3ab208120 + workspace/settingsModal/team/loading: 82b4ea7ed1439094d7c4be13aaba9a66 + workspace/settingsModal/team/reactivateSubscription: 7b867ef34a9109b3ad5136e39825a619 + workspace/settingsModal/team/leaveOrganization: 55d65595fdb2c0b3a2f98211bd568dd4 + workspace/settingsModal/team/removeTeamMember: dfd6d7c6e1c46626543c2aa509fbde90 + workspace/settingsModal/team/leaveOrganizationDescription: fdb3af50ba5fd4cd9f4962af9b46cc8f + workspace/settingsModal/team/removeMemberDescription: db8e926577ff3694fc7847d910a77f64 + workspace/settingsModal/team/alsoReduceSeatCount: e619ea5b14a179b732f97acafa69d62b + workspace/settingsModal/team/reduceSeatCountDescription: 9a01e132de6f0e5231ae3fa981db40c0 + workspace/settingsModal/team/thisActionCannotBeUndone: 3d8b13374ffd3cefc0f3f7ce077bd9c9 + workspace/settingsModal/team/cancel: 2e2a849c2223911717de8caa2c71bade + workspace/settingsModal/team/remove: dba2fe5fe9f83f8078c687f28cba4b52 + workspace/settingsModal/team/inviteUnavailableMessage: 92d07952f95f2cb0e0784fede9de36b2 + workspace/settingsModal/team/yourself: ee79cf988d6f4f960224d87785ea9ecd + workspace/settingsModal/team/thisMember: 01c1d66db074cb868b9b6c1f0415ac56 + workspace/settingsModal/team/noPublicAdjustableTier: fdc6e007d158b9e0a6edae9a885f06ca + workspace/settingsModal/team/addSeats/title: 6768a3936d0800216dc780f8d13dfe6d + workspace/settingsModal/team/addSeats/description: d875fcd1ae8944bfcc7d484b3b16d10a + workspace/settingsModal/team/addSeats/confirm: fa03bb6d344a835a92acaf10826e898d + workspace/settingsModal/team/billing/title: c3aea9c6cdbb39521ec948970f70d5c9 + workspace/settingsModal/team/billing/description: 4515d3df5d39e9082ec3deb565d4bbf2 + workspace/settingsModal/team/billing/organizationBillingRequired: 7cccec46c2247ba40e0f9d45234c0056 + workspace/settingsModal/team/billing/organizationBilledTitle: c08bb7453004a2d673c3c35edf5a409a + workspace/settingsModal/team/billing/organizationBilledEmpty: 5e8c2aeb7201fabd58740698af981827 + workspace/settingsModal/team/billing/availableOwnerBilledTitle: b7370a4190339f7985a0efee061a8a49 + workspace/settingsModal/team/billing/availableOwnerBilledEmpty: 6173aa181b5407e17dbd786ed5a11c50 + workspace/settingsModal/team/billing/returnToOwner: c1b8df4a656b74b84d4edfa5afb2d8d3 + workspace/settingsModal/team/billing/billToOrganization: 988063d0b9b16dfa28945f7fb01c3757 + workspace/settingsModal/team/billing/organization: 3dc8489af7e74121f65ce6d9677bc94d + workspace/settingsModal/team/billing/ownerBilling: 141c6a3fc6fd1a9d15413c50c29c9ac0 + workspace/settingsModal/team/billing/ownerLabel: 20de10b823f885dcf0986886b8c0f49c + workspace/settingsModal/team/members/title: d148d3e9f3d013feddc4f41bb620ec28 + workspace/settingsModal/team/members/empty: b3c8b0bf07af0643b1aa28af63b1b210 + workspace/settingsModal/team/members/sharedUsage: 79c6b20b91486fa00216e5352fdea058 + workspace/settingsModal/team/members/pending: 030a6f3395d5d4efddd3cc67d6009039 + workspace/settingsModal/team/members/billing: b01dbdd049ebbd4a349fa64d6ce65a3b + workspace/settingsModal/team/members/usage: 9ee126f9eeb9a2ecc83946e21c61eedb + workspace/settingsModal/team/members/sharedPool: 2e1f3e3a43937d3b736b570c5349bacf + workspace/settingsModal/team/members/unknown: 5cd12b882fe90320f93130c1b50e2e32 + workspace/settingsModal/team/members/removeMemberTooltip: 1d77c2ca3768e486fd1fb5df30cb60d9 + workspace/settingsModal/team/members/cancelling: 6dcf56d1fa7ed307f0f8b41cc351093f + workspace/settingsModal/team/members/cancelInvitationTooltip: 0d45f29a0417e8405834c4167b9924f7 + workspace/settingsModal/team/members/leaveOrganization: e74132cb4a0dc98c41e61ea3b2dd268b + workspace/settingsModal/team/invitation/title: 032c785f17699bd685a776ec57bb9f0d + workspace/settingsModal/team/invitation/description: f09e2a06aa49e5c1fa3f8f0668b7a3e3 + workspace/settingsModal/team/invitation/emailPlaceholder: 03e1a04b846b5c2d57591802c4f12c1e + workspace/settingsModal/team/invitation/hideWorkspaces: 1418fc1539194fbb87772b2a9af8f6ae + workspace/settingsModal/team/invitation/addWorkspaces: 30cc83a257a99d606347bb59d57a44b9 + workspace/settingsModal/team/invitation/invite: 181884cea804cbde665f160811ee7ad0 + workspace/settingsModal/team/invitation/noSeats: 60525674de770a80087fd7abab1746e2 + workspace/settingsModal/team/invitation/unavailable: 3f01540a98ebe6021f85305e039c54a1 + workspace/settingsModal/team/invitation/workspaceAccess: 32407b39cf878fb579559c1ed3660892 + workspace/settingsModal/team/invitation/optional: 396fb9a0472daf401c392bdc3e248943 + workspace/settingsModal/team/invitation/selected: d5aa727c1774eac70a94e74802c9f806 + workspace/settingsModal/team/invitation/grantAccess: d33ec6a850b96ffb87b62bb605164b84 + workspace/settingsModal/team/invitation/noWorkspacesAvailable: e51ba0f367dd35e3583b63eb93b5b2e8 + workspace/settingsModal/team/invitation/needAdminAccess: 2e51ac7cce1536ca3752819cac3b9b8a + workspace/settingsModal/team/invitation/owner: 9c0b3aeca046c3ebd3bdc352edc83e88 + workspace/settingsModal/team/invitation/sentSuccess: 86fdbf18f95b3ae69fe4138c767b5380 + workspace/settingsModal/team/invitation/sentSuccessWithAccess: ab4ae10d89cd57583c5188e3ceb7d163 + workspace/settingsModal/team/invitation/invalidEmail: 4157c206eb09e5f58cd1804be8689c55 + workspace/settingsModal/team/invitation/permissions/read/label: 2494ca23d10e5b6381eb271aceeb5270 + workspace/settingsModal/team/invitation/permissions/read/description: fdabeb6be5474013284e78385e490c7a + workspace/settingsModal/team/invitation/permissions/write/label: 0c8cd5e85bbf4a4ef271c6df37736df3 + workspace/settingsModal/team/invitation/permissions/write/description: aa30f0b4cc7347e8af4c490c51dac914 + workspace/settingsModal/team/invitation/permissions/admin/label: 90eb20f1400db82ab874744e47836dc6 + workspace/settingsModal/team/invitation/permissions/admin/description: d48aa5c7df80fce4609b4b3790f747a3 + workspace/settingsModal/sso/providerStatus: fefb6dd439a8d0e59cd05e8813da7ef0 + workspace/settingsModal/sso/issuerUrl: 6a555b82162f425366c27d768207dac1 + workspace/settingsModal/sso/providerId: 6659fed03fd34a827f4f58141926c16b + workspace/settingsModal/sso/callbackUrl: 70c960d4070aab46ed6df3dd1ad9e85c + workspace/settingsModal/sso/providerType: 7849722fb81a2891eba3a2678135d314 + workspace/settingsModal/sso/providerTypeDescriptions/oidc: ccc97823f0ec885067ae90bc306e9992 + workspace/settingsModal/sso/providerTypeDescriptions/saml: 921561ebb50cb437657ea9f5c6f0c7d8 + workspace/settingsModal/sso/selectProviderHelp: 603a7fc306d3e44d064384387b39a4e7 + workspace/settingsModal/sso/issuerUrlHelp: 6cf19a058a6dae5b44eb39c3b09f7f61 + workspace/settingsModal/sso/providerIdPlaceholder: bfd5af9e20c61a1e53a691c58680220a + workspace/settingsModal/sso/issuerUrlPlaceholder: d56a712740986c41f45e08b2330642d5 + workspace/settingsModal/sso/domain: 402d46965eacc3af4c5df92e53e95712 + workspace/settingsModal/sso/domainPlaceholder: 6c35e41ffbca5f0d38540ec54f5a0496 + workspace/settingsModal/sso/clientId: c5d7b5e169d45e257079739d7f93f377 + workspace/settingsModal/sso/clientIdPlaceholder: fa2df32c674e368493314e56cfa43952 + workspace/settingsModal/sso/clientSecret: e6059e208bcc3569f64ab6d69886006e + workspace/settingsModal/sso/clientSecretPlaceholder: 2e3c88e27b8eb28f0ccef31140836cbf + workspace/settingsModal/sso/scopes: 8fd98b2e43a81cf8b256deb28107b0d4 + workspace/settingsModal/sso/selectProviderId: bfd5af9e20c61a1e53a691c58680220a + workspace/settingsModal/sso/copyCallbackUrl: 243bf39c37af27554ac3478064d89864 + workspace/settingsModal/sso/showClientSecret: f994dcd17ef1d528452f625abf2c860c + workspace/settingsModal/sso/hideClientSecret: 3956c216a2388d313526ad38f009acc2 + workspace/settingsModal/sso/scopesPlaceholder: 89c82979a34e19d1d392e14c62ba7392 + workspace/settingsModal/sso/scopesDescription: 9608cfffa3e87325459c470ac4e157c4 + workspace/settingsModal/sso/entryPoint: af4126a4c5a7096b2c39cd07964a16e0 + workspace/settingsModal/sso/entryPointPlaceholder: 1e29cacb406ede69712f15e2da800952 + workspace/settingsModal/sso/entryPointDescription: 0be29439f5af465dafccc75d2a538b0a + workspace/settingsModal/sso/certificate: f78bff9f68314b5644b92c2a233096a8 + workspace/settingsModal/sso/certificatePlaceholder: 1d0cb986ec5358c4f0054ef2c330e6fe + workspace/settingsModal/sso/certificateDescription: 57f681de5fadd8d1adb6fad93c0be7c9 + workspace/settingsModal/sso/advancedOptions: c866b7a521ae6531aefb650b8391e64d + workspace/settingsModal/sso/audience: 7a69cb5e0c700c5717ed3fb724cb3a02 + workspace/settingsModal/sso/audiencePlaceholder: b65e794eaa5f38a97e0242184810a955 + workspace/settingsModal/sso/audienceDescription: 1c5d1e25881fde5ae5f089d8f8b9b667 + workspace/settingsModal/sso/callbackUrlOverride: 025d6fc8f294dfebc656ecdd3b96d13b + workspace/settingsModal/sso/callbackUrlPlaceholder: cb291cedf15794abefe06bf96bc46c39 + workspace/settingsModal/sso/callbackUrlDescription: 4eab07b50fcfbc8e70beb86de484cd5d + workspace/settingsModal/sso/requireSignedAssertions: 420bbd677f19b5bb18b55fd1c28fee0d + workspace/settingsModal/sso/metadataXml: 741e3f13a5a63d5d33ff2e48170a1960 + workspace/settingsModal/sso/metadataPlaceholder: 46edc2d4cde97780c423607ca393bfb9 + workspace/settingsModal/sso/metadataDescription: 1c0b27c17c210c9e4cee7415bc5588d5 + workspace/settingsModal/sso/loadingProviders: 698521c7eab2a1905e9a00d3d64b72d4 + workspace/settingsModal/sso/noProviders: 9271a9c1568c8b1919c25415b182a631 + workspace/settingsModal/sso/save: d895276cde226e9225eca1e74aa799f4 + workspace/settingsModal/sso/create: 82e5e81b5075b124e02745e2d4a74447 + workspace/settingsModal/sso/configureProvider: 82e5e81b5075b124e02745e2d4a74447 + workspace/settingsModal/sso/saving: 7cb2d8f012d57db8a62187b65ef164dc + workspace/settingsModal/sso/configuring: 1b32a871a66799435397b036de850971 + workspace/settingsModal/sso/selectOrganization: db5982da7e24f5048ea37ff82116aedb + workspace/settingsModal/sso/onlyAdmins: decb916bb75aacf43a8d9de887e2148b + workspace/settingsModal/sso/disabledTier: 6f08805b21820bbabb3095e4b63eb1eb + workspace/settingsModal/sso/providerLoadError: fe1e0a400d08199ef303799d51159777 + workspace/settingsModal/sso/providerError: 09150f8eab9bba73dac11f546a751158 + workspace/settingsModal/sso/reloadError: 8413973231637a114612701b0b8f3759 + workspace/settingsModal/sso/validation/fieldRequired: cb418c986f9e6ed1ad0e378370e19d4a + workspace/settingsModal/sso/validation/providerIdRequired: 1d9fc35c45b348fd35c559baca17829f + workspace/settingsModal/sso/validation/providerIdPattern: 0744c027e2f0c1eb61360b96994e628a + workspace/settingsModal/sso/validation/issuerUrlRequired: 7db67dfc518ce34478a69ef851e3b36b + workspace/settingsModal/sso/validation/issuerUrlHttps: 6ef43d6efab964746de4f6d9cd8b9d73 + workspace/settingsModal/sso/validation/issuerUrlValid: f9aa29058894928b4e6b7a0f5c17c665 + workspace/settingsModal/sso/validation/domainRequired: 8758f16246c5fad985995e500e7db41b + workspace/settingsModal/sso/validation/domainNoProtocol: cbff53d892f291b90da3b783aa07de58 + workspace/settingsModal/sso/validation/domainValid: a635a2b64355c89d0c6c37b38b6accaa + workspace/settingsModal/sso/validation/clientIdRequired: 7c1b60118adf8cabbf535719a8d65d2d + workspace/settingsModal/sso/validation/clientSecretRequired: aed28526e151901e13c602cd92aed7be + workspace/settingsModal/sso/validation/scopesRequired: e5bf84756dd0267d74a3dafc743b21fe + workspace/settingsModal/sso/validation/entryPointRequired: 6d29bed9e7ddfaa198b41621168cd682 + workspace/settingsModal/sso/validation/certificateRequired: 0e5948386ff8523c8ad56239bf831afb + workspace/settingsModal/sso/validation/providerLoadFailed: 36550211643fdbe4f6b8ede5801291e4 + workspace/settingsModal/sso/validation/providerLoadFailedGeneric: fe1e0a400d08199ef303799d51159777 + workspace/widgets/selector/categories/list: 9f4a73afc8de321175d71935134ef066 + workspace/widgets/selector/categories/editor: 15d1c1521efc08cd482f7ac9e1df0acf + workspace/widgets/selector/categories/utility: 22d60455dff2321a5bef4156abea4cb1 + workspace/widgets/selector/selectWidget: 209e9a2a2a67966b7393ab4748d000e5 + workspace/widgets/selector/widgetSelectionUnavailable: 7ded8aa859c4294d7794d0e21f2fe6c2 + workspace/widgets/titles/empty: 75268f15c0288ee06869a66c8862e7eb + workspace/widgets/titles/data_chart: c96ae0b8208edea8071631cbb9886a20 + workspace/widgets/titles/workflow_list: b0c9c8615a9ba7d9cb73e767290a7f72 + workspace/widgets/titles/editor_workflow: 666c102774fbd2528df9499b61cba7a1 + workspace/widgets/titles/workflow_chat: 5368faf5da035c24b9c4301b13bc6187 + workspace/widgets/titles/workflow_console: 9becc23823a424c5a221aa1eea5bc867 + workspace/widgets/titles/copilot: a912b3c7fb996fefccb182cf5c4a3fbc + workspace/widgets/titles/list_indicator: 6ba16bc4a2ba376582a8f1bc3612a58f + workspace/widgets/titles/list_mcp: 4f36fd9fcd34b148a7af2ed111723a79 + workspace/widgets/titles/editor_indicator: 83e18b158be0b1f39ce939d41bdb83a0 + workspace/widgets/titles/editor_mcp: 815257fc9ad2ab9deb15a51d70a1c9cc + workspace/widgets/titles/list_custom_tool: 196dd434e64721c7024b74b478ea2d2a + workspace/widgets/titles/editor_custom_tool: f31767a6622f1c593e0424137bc370db + workspace/widgets/titles/list_skill: cd71a8472065ec65b7d7863c1e201328 + workspace/widgets/titles/editor_skill: 4e5057b6a81faa3e7f885cfed5079d0b + workspace/widgets/titles/workflow_variables: 30215dc1ff3adfea4ffdfd0ff3fd41be + workspace/widgets/titles/watchlist: 53f704f96722017a0a4d47ad2f141b8f + workspace/widgets/empty/noWidgetSelected: f2e949bd1bdb503c2ed80a6d8757712b + workspace/widgets/empty/emptyWidget: c16b64a564042922f7a45616b34b5670 + workspace/widgets/empty/noWidgetDescription: 18a811006ebcf4b30e81026988cbb23f + workspace/widgets/empty/emptyWidgetDescription: a99d8c006a9b66c4c18b7c51288584b6 + workspace/widgets/empty/chooseWidget: c01775f4051a85a57009c117eafa73c4 + workspace/widgets/empty/surfaceTitle: 75268f15c0288ee06869a66c8862e7eb + workspace/widgets/empty/surfaceDescription: f0c88c28da6d8a1580cfe721a33d2f46 + workspace/widgets/workflowDropdown/selectWorkspaceFirst: f40103bc18b9d96e6333bba015841694 + workspace/widgets/workflowDropdown/unableToLoad: 6c2ed85c6d0342d03940c11eb23f34c9 + workspace/widgets/workflowDropdown/workflowSelectionUnavailable: a6f04ad402525b93c58d29cc0752fec5 + workspace/widgets/workflowDropdown/selectWorkflow: 6b2e20487ee117f26887ecd7784a617f + workspace/widgets/workflowDropdown/searchPlaceholder: 38aedad9fc718854b020e915c46f165c + workspace/widgets/workflowDropdown/failedToLoad: e7095d8722681ec1cbe80dcf837a6b78 + workspace/widgets/workflowDropdown/retry: 6e44d18639560596569a1278f9c83676 + workspace/widgets/workflowDropdown/loading: 8dd5796a8e3cf4dd5b18f66628c9ce31 + workspace/widgets/workflowDropdown/noWorkflowsAvailable: 2330a576f55ff99f49ffce80c48f8420 + workspace/widgets/workflowDropdown/noWorkflowsFound: 3063988aac5626562268cae7f8ce41bc + workspace/widgets/workflowDropdown/untitledWorkflow: 05913f9670d2825a31dd918181b81526 + workspace/widgets/mcpDropdown/selectWorkspaceFirst: f40103bc18b9d96e6333bba015841694 + workspace/widgets/mcpDropdown/unableToLoad: 825c2807459ac8ebb102493a817d9c0f + workspace/widgets/mcpDropdown/mcpSelectionUnavailable: b5e79b1710ba96b8f52258aa663abef2 + workspace/widgets/mcpDropdown/selectMcpServer: ecdeb40c05f5bb2ef49a72981a72ddee + workspace/widgets/mcpDropdown/searchPlaceholder: c7e16a070880e4f62e7ba63613f9cd4a + workspace/widgets/mcpDropdown/failedToLoad: 32bc80ccb7977037acff63ef13f63ae8 + workspace/widgets/mcpDropdown/retry: 6e44d18639560596569a1278f9c83676 + workspace/widgets/mcpDropdown/loading: 9c2541af6c686911e51c03c9cb4b15ac + workspace/widgets/mcpDropdown/noServersAvailable: 5c24c5ebc8ef682796b223728ad209fc + workspace/widgets/mcpDropdown/noServersFound: 5728078a224c7e748b4753b4aab0654b + workspace/widgets/mcpDropdown/unnamedServer: 96d9ebf9c891668e1af610c8adec2287 + workspace/widgets/console/selectWorkspace: 9ae07dd3c40e7de00c3402fca9d25015 + workspace/widgets/console/noWorkflows: c5f0a30c532f2f498d225865ff8bab46 + workspace/widgets/console/filters: acf5accc113ff3c1992688058576732c + workspace/widgets/console/status: 4e1fcce15854d824919b4a582c697c90 + workspace/widgets/console/error: 3c95bcb32c2104b99a46f5b3dd015248 + workspace/widgets/console/info: 5a6cc405211f675152b2460292fe0fb9 + workspace/widgets/console/blocks: 9ca4811d4b82738ea9912bc42e425893 + workspace/widgets/console/sortByTime: 1fab0bf0a492eda4ef0bf140946ba2d0 + workspace/widgets/console/structuredView: 94f0c6e1e961efd276bc07ac0a941bc4 + workspace/widgets/console/toggleStructuredView: 085d62d14240968498c6ab1aa3569491 + workspace/widgets/console/wrapText: 1acdf97b1eae7ce7599a55d664d5f503 + workspace/widgets/console/toggleWrapText: 136a7210b24f93b259d6301db0b7c4a5 + workspace/widgets/console/downloadConsoleCsv: 32335a23808858cb618942bfbfe72a3c + workspace/widgets/console/downloadCsv: 13bcd46bbdc2d7d096a862afe59e91c5 + workspace/widgets/console/clearConsole: b0827eb86588fe8bd935cf658f08a34d + workspace/widgets/console/noResults: 40c10fba4a2543a218f23b47db206dfe + workspace/widgets/mcpEditor/selectWorkspaceToEdit: 4ad3326a2ac1b45435789d82073eb637 + workspace/widgets/mcpEditor/selectServerToEdit: 13a2e4b6e175cb3723aad404a2dc9c1a + workspace/widgets/mcpEditor/saveDraftHint: 2f7765dfc7c8656efd129fb95b633c0b + workspace/widgets/mcpEditor/failedToLoadMcpServers: dc4ba8ac6649b14e58a295e6f0abe9e2 + workspace/widgets/mcpEditor/tools: b97917281ca74e5b9de6122632979796 + workspace/widgets/mcpEditor/saveRequired: 87d9ab1f61dea3aeb98c908f0b07eacc + workspace/widgets/mcpEditor/noToolsDiscovered: 49231f903cf9affaadad6e9d705651bf + workspace/widgets/mcpEditor/saveThisServerToRefreshAndInspectDiscoveredMcpTools: d479776e7fd5d5e919e12ec3e0c83bd2 + workspace/widgets/mcpEditor/refreshTools: 4a2b40ba2f0fe22e31e7185c2c76a261 + workspace/widgets/mcpEditor/testConnection: 6bddfcf3e2a1e806057514093a3fe071 + workspace/widgets/mcpEditor/resetForm: d81695ea4f3bf96d35e36b6a40dcc508 + workspace/widgets/mcpEditor/saveServer: 996248ff0e3145a287feaa0d87832256 + workspace/widgets/mcpEditor/clearSelection: af5d720527735d4253e289400d29ec9e + workspace/widgets/mcpEditor/selectServer: ade64a199d812be2592472bd12066cc6 + workspace/widgets/mcpEditor/serverNameRequired: 815761d1967e18333d038e41f99c0bdd + workspace/widgets/mcpEditor/failedToRefreshMcpServer: 8ff61b5cd0a4711f6292c1199768d413 + workspace/widgets/mcpEditor/failedToSaveMcpServer: fc99a1344957f974fc75446b2879b4ac + workspace/widgets/mcpEditor/toolCount: bbe803c749fae522bf928b3e07ccf6b5 + workspace/widgets/mcpEditor/loading: 82b4ea7ed1439094d7c4be13aaba9a66 + workspace/widgets/mcpEditor/connected: aa0ceca574641de34c74b9e590664230 + workspace/widgets/mcpEditor/error: 3c95bcb32c2104b99a46f5b3dd015248 + workspace/widgets/mcpEditor/draft: e8a92958ad300aacfe46c2bf6644927e + workspace/widgets/mcpEditor/disconnected: d9d377835b64c098c5ac32efcebc093d + workspace/widgets/mcpEditor/unnamedServer: 96d9ebf9c891668e1af610c8adec2287 + workspace/widgets/mcpEditor/updated: 0c1fbdcd9fcd781dfe448c22a053f56b + workspace/widgets/mcpEditor/toolsRefreshed: 4293863caf1a0440dc5147a93facac3b + workspace/widgets/mcpEditor/lastConnected: bb6f00788023587aaa93778f8a7e0372 + workspace/widgets/mcpEditor/lastError: a0ff525c6db595892f69fd95760b1928 + workspace/widgets/triggerList/coreTriggers: 8212121d9897d0f7dfd7248ecf25e42f + workspace/widgets/triggerList/integrationTriggers: e4f5273e0a4e55f943f9b628561a5f72 + workspace/widgets/triggerList/searchPlaceholder: d7105b890c8a51d5569a0195da227eb6 + workspace/widgets/triggerList/openTriggerList: 9084279b922987939a34f8414d7b3fc7 + workspace/widgets/triggerList/close: 2c2e22f8424a1031de89063bd0022e16 + workspace/widgets/triggerList/noResults: 40c10fba4a2543a218f23b47db206dfe + workspace/widgets/workflowToolbar/selectWorkspace: f67c0a9262764e2cb45c1b30ab972fdf + workspace/widgets/workflowToolbar/blocks: 9ca4811d4b82738ea9912bc42e425893 + workspace/widgets/workflowToolbar/tools: b97917281ca74e5b9de6122632979796 + workspace/widgets/workflowToolbar/triggers: 66488f38662a4199fb8a18967239c992 + workspace/widgets/workflowToolbar/special: 59c9d9b1bffc40a84acdda1fdd958351 + workspace/widgets/workflowToolbar/browseLabel: b17cbd3f7d48225922124bb99133f1bd + workspace/widgets/workflowToolbar/searchPlaceholder: e2ebe31b37e4965a68d5663dd11c7a59 + workspace/widgets/workflowToolbar/noResults: babb3d60174adf86fce10ffd241159cd + workspace/widgets/workflowLabels/systemPrompt: 3980ee4b3313840de80cdbeeb510a573 + workspace/widgets/workflowLabels/userPrompt: 407a75273271e5d115a337849780197d + workspace/widgets/workflowLabels/model: ded909b246142d37d4fdc47ae2eadb3b + workspace/widgets/workflowLabels/temperature: 8636978f8f6086b141e66f53eb87e1ac + workspace/widgets/workflowLabels/apiKey: ce825fec5b3e1f8e27c45b1a63619985 + workspace/widgets/workflowLabels/skills: cd71a8472065ec65b7d7863c1e201328 + workspace/widgets/workflowLabels/tools: b97917281ca74e5b9de6122632979796 + workspace/widgets/workflowLabels/responseFormat: 4a8a5d89a6ecb528644e4138963ed1a0 + workspace/widgets/workflowLabels/reasoningEffort: 15445d0e8c0fb10879eea19274bc3b4d + workspace/widgets/workflowLabels/verbosity: 33bddeb4a9ab511c95af2934031893fd + workspace/widgets/workflowLabels/configured: 3c0671feaabf660951709ea3f7a72a5a + workspace/widgets/workflowLabels/value: 34b0eaa85808b15cbc4be94c64d0146b + workspace/widgets/workflowLabels/items: 483242ac9e6c79d23569820b224ff9d0 + workspace/widgets/workflowLabels/fields: dc5e476f6beca227b505d21489e8d365 + workspace/widgets/workflowLabels/object: 317ffa9365a5c322e3021224ce2f0369 + workspace/widgets/workflowLabels/block: ab622667cee15257344842ccf5faafc0 + workspace/widgets/workflowLabels/type: f04471a7ddac844b9ad145eb9911ef75 + workspace/widgets/workflowLabels/none: d163ad01ba50118a86047c8b73fbe922 + workspace/widgets/workflowLabels/noValuesToDisplay: 9ecec275a453d8e120ed8b6412837aea + workspace/widgets/workflowLabels/error: 3c95bcb32c2104b99a46f5b3dd015248 + workspace/widgets/workflowLabels/if: 24e33d879091c4e0e90a9fdb50e1b020 + workspace/widgets/workflowLabels/else: 9e690b1f2036784f03c0d3f63f4fc9af + workspace/widgets/workflowLabels/elseIf: 1f644ade33784698a3780b24898aa671 + workspace/widgets/workflowLabels/addSkill: 671a038e9811029822a79a395c0a9b71 + workspace/widgets/workflowLabels/searchSkills: 271f5afc53d804137b7b39490682303a + workspace/widgets/workflowLabels/chooseModel: b56cd632ddc4fdb611afb3def1aaab42 + workspace/widgets/workflowLabels/lite: 9d4a3432cecd057a75158851c6d8f4f8 + workspace/widgets/workflowLabels/anthropic: cef2eed0c62f6a7dc0fe0df652bfb162 + workspace/widgets/workflowLabels/openai: 386a12cfe55562a8e181c9bfcac6b156 + workspace/widgets/workflowLabels/nextStep: f1b1016308534343ff1506a52960a7da + workspace/widgets/workflowLabels/locked: 30950d887a19841aa481091affd091e6 + workspace/widgets/workflowLabels/deployed: d1499f5ad90040597b133faef5633a36 + workspace/widgets/workflowLabels/deployedWithVersion: 48ed5142dab45abea3fd46b532275847 + workspace/widgets/workflowLabels/notDeployed: fbd7d64f545b13a0bd6e037d608702e0 + workspace/widgets/workflowLabels/disabled: 0889a3dfd914a7ef638611796b17bf72 + workspace/widgets/workflowLabels/removeSkill: a583d8f150410806c2eed3ffcbb99ad8 + workspace/widgets/workflowLabels/currentWorkflow: fbf722075638ed1d2a6dd267b59f1a9a + workspace/widgets/workflowLabels/currentSkill: 4b7d9a42c58f970771882d7a8eeb229b + workspace/widgets/workflowLabels/currentTool: ba56f8ef750cd6ac25def814be981b29 + workspace/widgets/workflowLabels/currentIndicator: 71d15466f2898b16c0cd578760af7307 + workspace/widgets/workflowLabels/currentMcpServer: 1482abe1e3294adef8715659ee086bf1 + workspace/widgets/workflowLabels/workflows: b0c9c8615a9ba7d9cb73e767290a7f72 + workspace/widgets/workflowLabels/customTools: 196dd434e64721c7024b74b478ea2d2a + workspace/widgets/workflowLabels/indicators: 6ba16bc4a2ba376582a8f1bc3612a58f + workspace/widgets/workflowLabels/mcpServers: 4f36fd9fcd34b148a7af2ed111723a79 + workspace/widgets/workflowLabels/allWorkflows: 3adaa4f8f91c3e477f903bd1ad6a4481 + workspace/widgets/workflowEditor/previewInspector: 6e45b39db9fd1998bce8427b95b7861c + workspace/widgets/workflowEditor/selectBlockToViewPreviewDetails: 0e7ea2d94fbb847de2a233e933f35089 + workspace/widgets/workflowEditor/nodeNotFound: 8a0c2d7ddcd3d2f7010b31f8fd0efe68 + workspace/widgets/workflowEditor/selectedNodeUnavailable: 8c0a66f95de2d7aa324d6e356c8ce3c3 + workspace/widgets/workflowEditor/missingBlockConfiguration: afbde784c1d528a20ac567860bbc6bbc + workspace/widgets/workflowEditor/saveName: 7b186baba422fc0b558f5e93655eb342 + workspace/widgets/workflowEditor/renameNode: 81f4258f8517b3407f827fb4be9b938d + workspace/widgets/workflowEditor/loopTypeLabel: c4655e3ac8fe52ade74839987160442c + workspace/widgets/workflowEditor/parallelTypeLabel: 81bc626502f707842e575d29c88cfa66 + workspace/widgets/workflowEditor/selectType: fa373e47f55ff081982844a853be3a88 + workspace/widgets/workflowEditor/forLoop: 91efcbf0d980ca15cf0072c18ff8b7b1 + workspace/widgets/workflowEditor/forEachLoop: 7f9c4846e984c01b107c41e53da420e7 + workspace/widgets/workflowEditor/whileLoop: d953f34f52ffa51420ef59d98cff953b + workspace/widgets/workflowEditor/doWhileLoop: 9480ae527b7ccdd5aa36e174dc3bbe25 + workspace/widgets/workflowEditor/parallelCount: ad1c710d7fad4ca50f67faf3a4110e74 + workspace/widgets/workflowEditor/parallelEach: 0402f1c402ed384f07b6e58105d1bea0 + workspace/widgets/workflowEditor/loopIterations: 5357ebd65f7523dcc8eb3f3c58577466 + workspace/widgets/workflowEditor/parallelExecutions: 6a31e222199408cc632aa37ffe3f2a82 + workspace/widgets/workflowEditor/whileCondition: aab58336ccde6d2a25b50be8c609545a + workspace/widgets/workflowEditor/collectionItems: 4f72f11410154eef6a72490bad304487 + workspace/widgets/workflowEditor/parallelItems: f9a28f8181261219dedfb1349a814128 + workspace/widgets/workflowEditor/enterValueBetween: 40459d03fcf52474797bd8305f837388 + workspace/widgets/workflowEditor/hideAdditionalFields: 5c7103caa568650d8a6279d1b2e2236c + workspace/widgets/workflowEditor/showAdditionalFields: fd4664eb52cc0215454d245cfe15bef7 + workspace/widgets/workflowEditor/additionalFields: a710c55799790fdc4ce8b9d47a56eee0 + workspace/widgets/workflowEditor/triggerNoEditableFields: dbf692aaa51ac7c017dbd4f49606059b + workspace/widgets/workflowEditor/blockNoEditableFields: 6cd7a0a11ee12a2a51bfda8cbe7922fa + workspace/widgets/workflowEditor/requiredField: 41f31582f26b943b74e63ec06580a682 + workspace/widgets/workflowEditor/invalidJson: 8453051f114b6536d74fb47a3490fd09 + workspace/widgets/workflowEditor/unknownInputType: bf5e46abed0cb783235de95f1ecd0315 + workspace/widgets/workflowEditor/loop: da9b3a204aa0532ac89b6fc018a6088f + workspace/widgets/workflowEditor/parallel: 0cdd24b912c1ee72522916db76092c00 + workspace/widgets/workflowEditor/start: dbe56303d9a6d3fad1a8d4cbcc97f365 + workspace/widgets/workflowEditor/end: 3e042a423b7c20f9a7788e541aa6d7ea + workspace/widgets/apiKey/apiKey: ce825fec5b3e1f8e27c45b1a63619985 + workspace/widgets/apiKey/apiKeyType: 051adcf1d2da1c43d8fa314c5d9b3c14 + workspace/widgets/apiKey/apiKeyName: 6a8115f9388cbb6621fa719b123de878 + workspace/widgets/apiKey/personal: 1c619008563115c5c99acdab4e8c9717 + workspace/widgets/apiKey/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f + workspace/widgets/apiKey/selectApiKey: f00a919ee55cc92ed05ddc69b937b4b2 + workspace/widgets/apiKey/myApiKey: fa3193fceb01675d35ea033580016776 + workspace/widgets/apiKey/ownerIsBilledForUsage: 4fac956cfd7758f6dc5a5520ca802301 + workspace/widgets/apiKey/keyOwnerIsBilled: 5dfa731eb17b1ea655b23156120c0b3c + workspace/widgets/apiKey/createNew: 2b16571245d9f0842334895de31c80f4 + workspace/widgets/apiKey/loadingApiKeys: 435f965f6a38974e03c4d85627c19a43 + workspace/widgets/apiKey/selectAnApiKey: 47f6683480638d8ee2f2ee18d188f523 + workspace/widgets/apiKey/noApiKeysAvailable: bcf3b7a83cfe3c7a0980cee97996c58d + workspace/widgets/apiKey/createNewApiKey: 429436182278080d4450e8cda2c85d6f + workspace/widgets/apiKey/workspaceAccess: 174cbfac3c1730193b934ae29a4beaf0 + workspace/widgets/apiKey/personalAccess: 691ef11a2db29322181e99debb81da03 + workspace/widgets/apiKey/apiKeyHasBeenCreated: b102bb155e692fae272045784dbd13d6 + workspace/widgets/apiKey/onlyTimeYouWillSeeYourApiKey: a25a874d02b4aa392e9e9103cda9bbdd + workspace/widgets/apiKey/copyItNowAndStoreItSecurely: ed7b6ecd6f7acde662bbb1f2d488cd2f + workspace/widgets/apiKey/copyToClipboard: 8992c838874db83e3cc89c9fb4442bfd + workspace/widgets/apiKey/enterName: aec4b4cfd86e5281339f50bdaeabc988 + workspace/widgets/apiKey/failedToCreate: 2874ce0eed71e0e4afb70784593984e4 + workspace/widgets/apiKey/create: 757ccd28dd533ff3a933355273c1e32a + workspace/widgets/apiKey/creating: c949fe6aa47b326f345b405fe9c4d6e7 + workspace/widgets/apiKey/cancel: 2e2a849c2223911717de8caa2c71bade + workspace/widgets/apiKey/workspaceLabel: b63ef0e99ee6f7fef6cbe4971ca6cf0f + workspace/widgets/apiKey/personalLabel: 1c619008563115c5c99acdab4e8c9717 + workspace/widgets/entityEditor/undo: 6fa10b811e2894dcdd73718f66c1b481 + workspace/widgets/entityEditor/redo: 9b58c5e59cbb74536207137a4a447c9b + workspace/widgets/pairColor/selectionUnavailable: 7bdb8b9fa4b316d10e8b8089253e6a21 + workspace/widgets/pairColor/selectWidgetColor: e8a5a841d73dfa5b67830666e5821029 + workspace/widgets/pairColor/unlinked: 87bd23ab3977103d2a70c1cbd24fbc01 + workspace/widgets/pairColor/red: bace0083b78cdb188523bc4abc7b55c6 + workspace/widgets/pairColor/orange: 836094fa3fcf7488957f6d7cba7c1f60 + workspace/widgets/pairColor/blue: a5cf034b2d370a976119335cd99f4217 + workspace/widgets/pairColor/green: 482ff383a4258357ba404f283682471d + workspace/widgets/pairColor/purple: 547ba93b6f68317e2dfbafb16cabc30f + workspace/widgets/deployment/adminPermissionsRequiredToDeployWorkflows: ce73432144d9361d6aa299b6c5673926 + workspace/widgets/deployment/deploying: 84cc567b46b5680e6d1ea930653c559a + workspace/widgets/deployment/workflowChangesDetected: 0361316546c460112d4882775bb9c583 + workspace/widgets/deployment/deploymentSettings: 55e8dabe4780dc357ec73a37e0c29856 + workspace/widgets/deployment/deployWorkflow: f30396952769a8d382c34ef04dccb433 + workspace/widgets/deployment/deployApi: 47f4adc234c4ac6d6ad29db73a1b5e52 + workspace/widgets/deployment/needsRedeployment: ed6afbc9e0fe449b1484a1ad852111bc + workspace/widgets/deployment/deployWorkflowTitle: f30396952769a8d382c34ef04dccb433 + workspace/widgets/deployment/active: 93e6175c190bf235124f10f4d129c26a + workspace/widgets/deployment/close: 2c2e22f8424a1031de89063bd0022e16 + workspace/widgets/deployment/deploymentError: 3deeb06050d095fb1d5b2fd66c96a92a + workspace/widgets/deployment/expandSidebar: bb39e02ee97f75685875d0b1c89b9c91 + workspace/widgets/deployment/collapseSidebar: 8bd57070a13fce43a5c2af0d70148784 + workspace/widgets/deployment/selectSharedDeploymentApiKeyInBillingBeforeDeployingThisApiTrigger: 36c7a68f2715f1c567f8916c03d045ea + workspace/widgets/deployment/thisApiTriggerUsesTheSharedDeploymentApiKey: aaa9743c34747d7a80efa346b1ec776a + workspace/widgets/deployment/thisApiTriggerUsesTheSharedDeploymentApiKeySelectedInBilling: 913d269f601dcd173a5b13186fd2b3b1 + workspace/widgets/deployment/thisApiTriggerWillUseTheSharedDeploymentApiKeyCurrentlySelectedInBilling: 07c33ac1d5fbdaeaedfc5668cba6dfaa + workspace/widgets/workflowCreateMenu/createButtonTooltip: f4ecfe3b57ac7974c55ab9c63fc03c4d + workspace/widgets/workflowCreateMenu/selectWorkspaceTooltip: 1599ad4c8edf1d327acb765c0d219921 + workspace/widgets/workflowCreateMenu/createWorkflow: 556be5b31c361973a19d3e2f7375d4f3 + workspace/widgets/workflowCreateMenu/createFolder: 77d75787f135c7df93dc2e63473d320a + workspace/widgets/workflowCreateMenu/importWorkflow: 924e125dc645c70770e74773918b6281 + workspace/widgets/workflowCreateMenu/creating: c949fe6aa47b326f345b405fe9c4d6e7 + workspace/widgets/workflowCreateMenu/importing: 10334c8b895fcab96c9e141ffa548e28 + workspace/layoutTabs/createNewLayout: 9d3104298dac94784e7c1fdffea1ada1 diff --git a/apps/tradinggoose/i18n/messages/en.json b/apps/tradinggoose/i18n/messages/en.json new file mode 100644 index 000000000..c7a5a1938 --- /dev/null +++ b/apps/tradinggoose/i18n/messages/en.json @@ -0,0 +1,1800 @@ +{ + "meta": { + "landing": { + "title": "TradingGoose - Visual Workflow Platform for LLM Trading | Open Source", + "description": "Open-source platform for technical LLM-driven trading. Custom indicators in PineTS, live market monitors, and AI agent workflows triggered by market signals.", + "openGraphTitle": "TradingGoose - Visual Workflow Platform for LLM Trading", + "openGraphDescription": "Open-source platform for technical LLM-driven trading. Custom indicators in PineTS, live market monitors, AI agent workflows triggered by market signals.", + "seo": { + "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, AI trading assistant", + "socialPreviewAlt": "TradingGoose social preview", + "llmContentType": "visual workflow platform for trading, custom indicators, AI agent workflows for markets", + "llmUseCases": "signal-driven trade execution, portfolio rebalancing, indicator alerts, strategy backtesting, market sentiment analysis, custom trading dashboards", + "llmIntegrations": "OpenAI, Anthropic, Google Gemini, xAI, Mistral, Perplexity, Ollama, custom market data providers", + "llmPricing": "See hosted pricing on tradinggoose.ai" + } + }, + "blog": { + "title": "Blog | TradingGoose", + "description": "Articles about trading automation, workflow design, and building smarter strategies." + }, + "privacy": { + "title": "Privacy Policy | TradingGoose", + "description": "Privacy Policy for TradingGoose Studio, covering account data, workflows, connected services, analytics, billing, and retention practices." + } + }, + "nav": { + "docs": "Docs", + "blog": "Blog", + "login": "Login", + "menu": "Menu", + "homeLabel": "Home", + "languageLabel": "Language" + }, + "registration": { + "open": { + "primary": "Get Started", + "auth": "Sign up" + }, + "waitlist": { + "primary": "Join Waitlist", + "auth": "Join waitlist" + }, + "disabled": { + "primary": "Coming soon", + "auth": null + } + }, + "auth": { + "common": { + "email": "Email", + "password": "Password", + "fullName": "Full name", + "workEmail": "Work email", + "enterYourEmail": "Enter your email", + "enterYourPassword": "Enter your password", + "enterYourName": "Enter your name", + "enterYourWorkEmail": "Enter your work email", + "forgotPassword": "Forgot password?", + "showPassword": "Show password", + "hidePassword": "Hide password", + "continueWith": "Or continue with", + "or": "Or", + "signIn": "Sign in", + "signUp": "Sign up", + "alreadyHaveAccount": "Already have an account?", + "dontHaveAccount": "Don't have an account?", + "termsLeadSigningIn": "By signing in, you agree to our", + "termsLeadCreatingAccount": "By creating an account, you agree to our", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "and": "and", + "returnHome": "Return home", + "backToLogin": "Back to login", + "backToSignup": "Back to signup", + "verifyEmail": "Verify email", + "signInWithEmail": "Sign in with email", + "signInWithSso": "Sign in with SSO", + "continueWithSso": "Continue with SSO", + "requestAccess": "Request access" + }, + "error": { + "eyebrow": "Authentication error", + "codeLabel": "Error code", + "supportPrefix": "If this keeps happening, contact", + "supportLinkLabel": "support", + "supportSuffix": "and include the error code.", + "default": { + "title": "Something went wrong", + "description": "We could not complete that authentication request. Please try signing in again." + }, + "groups": { + "accountCreation": { + "title": "We couldn't create your account", + "description": "Your sign-up request did not complete. Try again from the sign-up form, or log in if this email is already registered." + }, + "accountExists": { + "title": "Account already exists", + "description": "An account with this email is already registered. Sign in instead of creating a new account." + }, + "emailVerification": { + "title": "Verify your email to continue", + "description": "Your account exists, but email verification still needs to be completed." + }, + "invalidCallback": { + "title": "This sign-in link is invalid", + "description": "The authentication callback was not valid. Start the sign-in flow again." + }, + "invalidToken": { + "title": "This authentication link is invalid", + "description": "The link or token could not be verified. Start the authentication flow again." + }, + "expiredToken": { + "title": "This authentication link has expired", + "description": "The link or token has expired. Start the authentication flow again." + }, + "sessionCreation": { + "title": "We couldn't start your session", + "description": "Authentication succeeded, but the session could not be created. Try logging in again." + }, + "sessionRestore": { + "title": "We couldn't restore your session", + "description": "Your session could not be loaded. Try logging in again." + }, + "sessionExpired": { + "title": "Your session has expired", + "description": "Sign in again to continue." + }, + "userInfo": { + "title": "We couldn't complete your sign-in", + "description": "We were unable to read your identity from the provider. Try signing in again." + }, + "providerUnavailable": { + "title": "This sign-in provider is unavailable", + "description": "The requested sign-in provider is not configured right now." + }, + "linkedAccount": { + "title": "This provider is already linked", + "description": "That sign-in provider is already connected to another account. Use a different method to continue." + }, + "waitlistLimited": { + "title": "Registration is limited", + "description": "Registration is limited to approved waitlist emails." + }, + "registrationDisabled": { + "title": "Registration is currently disabled", + "description": "Registration is currently disabled." + } + } + }, + "disabled": { + "title": "Registration closed", + "description": "Registration is currently disabled." + }, + "note": { + "waitlistApprovedEmail": "Use the same waitlist-approved email for any sign-in method." + }, + "social": { + "github": "GitHub", + "google": "Google", + "connecting": "Connecting..." + }, + "verify": { + "eyebrow": "Verification", + "pendingTitle": "Verify Your Email", + "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}}", + "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.", + "instructionsWithoutService": "Enter the 6-digit code to verify your account.", + "verifyButton": "Verify Email", + "verifyingButton": "Verifying...", + "resendPrompt": "Didn't receive a code?", + "resendIn": "Resend in {{countdown}}s", + "resendButton": "Resend", + "yourEmail": "your email", + "errors": { + "invalid": "Invalid verification code. Please check and try again.", + "expired": "The verification code has expired. Please request a new one.", + "generic": "Verification failed. Please check your code and try again.", + "attempts": "Too many failed attempts. Please request a new code.", + "resendFailed": "Failed to resend verification code. Please try again later." + } + }, + "login": { + "eyebrow": "Sign in", + "title": "Welcome back", + "description": "Enter your credentials", + "submit": "Sign in", + "submitting": "Signing in...", + "divider": "Or continue with", + "resetDialog": { + "title": "Reset Password", + "description": "Enter your email address and we'll send you a link to reset your password if your account exists.", + "emailLabel": "Email", + "emailPlaceholder": "Enter your email", + "emailRequired": "Please enter your email address.", + "emailInvalid": "Please enter a valid email address.", + "success": "Password reset link sent to your email", + "error": "Failed to request password reset", + "submit": "Send Reset Link", + "submitting": "Sending..." + }, + "validation": { + "emailRequired": "Email is required.", + "emailInvalid": "Please enter a valid email address.", + "passwordRequired": "Password is required.", + "passwordEmpty": "Password cannot be empty." + }, + "errors": { + "sessionExpired": "Your session expired. Please try signing in again.", + "emailSignInDisabled": "Email sign in is currently disabled.", + "invalidCredentials": "Invalid email or password. Please try again.", + "noAccount": "No account found with this email. Please sign up first.", + "missingCredentials": "Please enter both email and password.", + "emailPasswordDisabled": "Email and password login is disabled.", + "failedToCreateSession": "Failed to create session. Please try again later.", + "tooManyAttempts": "Too many login attempts. Please try again later or reset your password.", + "accountLocked": "Your account has been locked for security. Please reset your password.", + "network": "Network error. Please check your connection and try again.", + "rateLimit": "Too many requests. Please wait a moment before trying again.", + "unableToSignIn": "Unable to sign in.", + "unableToSignInNow": "Unable to sign in right now. Please try again." + } + }, + "signup": { + "eyebrow": "Sign up", + "title": "Create an account", + "descriptionOpen": "Create an account or log in", + "descriptionWaitlist": "Approved waitlist access required", + "submit": "Create account", + "submitting": "Creating account...", + "divider": "Or continue with", + "nameTitle": "Name can only contain letters, spaces, hyphens, and apostrophes", + "validation": { + "emailRequired": "Email is required.", + "emailInvalid": "Please enter a valid email address.", + "nameRequired": "Name is required.", + "nameEmpty": "Name cannot be empty.", + "nameCharacters": "Name can only contain letters, spaces, hyphens, and apostrophes.", + "nameSpaces": "Name cannot contain consecutive spaces.", + "nameTooLong": "Name will be truncated to 100 characters. Please shorten your name.", + "passwordMinLength": "Password must be at least 8 characters long.", + "passwordUppercase": "Password must include at least one uppercase letter.", + "passwordLowercase": "Password must include at least one lowercase letter.", + "passwordNumber": "Password must include at least one number.", + "passwordSpecial": "Password must include at least one special character." + }, + "errors": { + "failedToCreateAccount": "Failed to create account", + "accountExists": "An account with this email already exists. Please sign in instead.", + "emailSignupDisabled": "Email signup is currently disabled.", + "signupNotEnabled": "Email and password sign up is not enabled.", + "waitlistRequired": "This email is not approved for signup yet. Join the waitlist first.", + "invalidEmail": "Please enter a valid email address.", + "passwordTooShort": "Password must be at least 8 characters long.", + "passwordTooLong": "Password must be less than 128 characters long.", + "network": "Network error. Please check your connection and try again.", + "rateLimit": "Too many requests. Please wait a moment before trying again." + } + }, + "waitlist": { + "eyebrow": "Waitlist", + "title": "Request access to TradingGoose", + "description": "Join the queue for platform access. If your email is already approved, you can continue straight to signup from here.", + "helperText": "Use the email address you want reviewed for platform access.", + "submit": "Request access", + "submitting": "Submitting...", + "pending": "You are on the waitlist. We will review your request and let you know when access is available.", + "approvedPrefix": "Your email is approved. Continue to", + "signedUpPrefix": "This email already has access. Continue to", + "rejected": "This waitlist request is not approved for access.", + "signUpLink": "sign up", + "loginLink": "login", + "validation": { + "emailRequired": "Email is required.", + "emailInvalid": "Please enter a valid email address." + } + }, + "sso": { + "eyebrow": "SSO", + "title": "Sign in with SSO", + "description": "Enter your work email to continue", + "submit": "Continue with SSO", + "submitting": "Redirecting to SSO provider...", + "divider": "Or", + "emailButton": "Sign in with email", + "validation": { + "emailRequired": "Email is required.", + "emailInvalid": "Please enter a valid email address." + }, + "errors": { + "accountNotFound": "No account found. Please contact your administrator to set up SSO access.", + "ssoFailed": "SSO authentication failed. Please try again.", + "providerNotConfigured": "SSO provider not configured correctly.", + "invalidEmailDomain": "Email domain not configured for SSO. Please contact your administrator.", + "network": "Network error. Please check your connection and try again.", + "rateLimit": "Too many requests. Please wait a moment before trying again.", + "ssoDisabled": "SSO authentication is disabled. Please use another sign-in method.", + "failed": "SSO sign-in failed. Please try again." + } + } + }, + "localeNames": { + "en": "English", + "es": "Español", + "zh-CN": "简体中文" + }, + "landing": { + "hero": { + "statusBadges": { + "disabled": "Honk! TradingGoose-Studio coming soon", + "waitlist": "Honk! Introducing TradingGoose-Studio", + "open": "Honk! TradingGoose-Studio is here!" + }, + "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.", + "featureBadges": [ + "AI Agent Workflows", + "Custom Indicators", + "Bring Your Own Data", + "Integrations" + ], + "learnMore": "Learn More" + }, + "cta": { + "title": "Let AI agents work your trading strategy.", + "description": "See what the community is building with TradingGoose.", + "joinDiscord": "Join Discord", + "placeholder": "you@example.com", + "subscribe": "Get updates", + "subscribing": "Subscribing...", + "success": "Subscribed! Check your inbox.", + "error": "Something went wrong." + }, + "footer": { + "description": "AI workflow platform for technical LLM trading", + "copyright": "© {{year}} {{brand}}. Built for visual trading workflows.", + "links": { + "docs": "Docs", + "blog": "Blog", + "widgets": "Widgets", + "indicators": "Indicators", + "blocks": "Blocks", + "tools": "Tools", + "changelog": "Changelog", + "privacy": "Privacy Policy", + "licenses": "Licenses", + "terms": "Terms of Service" + }, + "social": { + "discord": "Discord", + "github": "GitHub" + }, + "hoverText": "HONK!" + }, + "preview": { + "shell": { + "headerAriaLabel": "Widget header", + "widgetLabel": "Widget" + }, + "layout": { + "headerAriaLabel": "Widget header", + "sizeLabel": "Widget Size" + }, + "indicatorDropdown": { + "placeholder": "Select indicators", + "tooltip": "Select indicators", + "searchPlaceholder": "Search indicators...", + "emptyWithQuery": "No indicators found.", + "emptyWithoutQuery": "No indicators available yet." + }, + "market": { + "indicatorUnavailableError": "Indicator is not available in this showcase." + }, + "workflow": { + "zoomOut": "Zoom out workflow preview", + "zoomIn": "Zoom in workflow preview", + "selectorAriaLabel": "Workflow preview selector", + "demos": { + "signalBriefing": "Signal Briefing", + "investmentDebate": "Investment Debate", + "riskRouting": "Risk Routing" + } + } + }, + "howItWorks": { + "eyebrow": "How it works", + "title": "From data to decision", + "description": "Connect your own data sources, monitor markets with custom indicators, let AI agents analyze what matters, and trigger workflows that act on your behalf.", + "processes": [ + { + "title": "Connect your data", + "description": "Plug in any market data provider and stream live prices into the workspace." + }, + { + "title": "Monitor with indicators", + "description": "Write custom PineTS indicators that watch for the conditions you care about." + }, + { + "title": "Analyze with AI agents", + "description": "Let LLM-powered agent blocks evaluate signals, assess risk, and make decisions autonomously." + }, + { + "title": "Trigger workflows", + "description": "When a signal fires, kick off a workflow to trade, alert, log, or anything else you define." + } + ] + }, + "monitorSection": { + "eyebrow": "Live monitors", + "title": "Indicators that trigger workflows", + "description": "Set up monitors that watch your indicators on live market data. When a signal fires, a workflow runs automatically - place orders, send alerts, log results, or anything else.", + "bullets": [ + "Connect any streaming data provider with your own credentials", + "Choose an indicator and interval to monitor per listing", + "Route triggers to any deployed workflow" + ], + "tableHeaders": { + "listing": "Listing", + "indicator": "Indicator", + "workflow": "Workflow", + "status": "Status" + }, + "statuses": { + "pending": "Pending", + "running": "Running", + "success": "Success", + "failed": "Failed" + }, + "indicatorOptions": [ + { + "name": "RSI < 30", + "color": "#8b5cf6" + }, + { + "name": "MACD Cross", + "color": "#14b8a6" + }, + { + "name": "EMA 21/50", + "color": "#f59e0b" + }, + { + "name": "Supertrend", + "color": "#ef4444" + }, + { + "name": "BB Squeeze", + "color": "#3b82f6" + }, + { + "name": "Volume Spike", + "color": "#10b981" + } + ], + "workflowOptions": [ + { + "name": "Sentiment Analysis", + "color": "#6366f1" + }, + { + "name": "Risk Assessment", + "color": "#f59e0b" + }, + { + "name": "Portfolio Rebalance", + "color": "#22c55e" + }, + { + "name": "Earnings Report Check", + "color": "#3b82f6" + }, + { + "name": "Social Media Scan", + "color": "#8b5cf6" + }, + { + "name": "Volatility Analysis", + "color": "#ef4444" + }, + { + "name": "Sector Correlation", + "color": "#14b8a6" + } + ] + }, + "features": { + "eyebrow": "Features", + "title": "Your workspace, your way", + "description": "Layouts, charts, and workflows - each designed to work on its own or together.", + "rows": [ + { + "badge": "Workspace", + "title": "Widget layouts", + "description": "Split the workspace to place widgets side by side or stacked. Save and switch between named layouts per workspace.", + "bullets": [ + "Recursive splitting", + "Saved layouts per workspace", + "Shared widget action menu" + ] + }, + { + "badge": "Charting", + "title": "Indicators and live data", + "description": "Built-in indicators and a PineTS editor for writing custom ones. Connect your own data provider and monitor prices in real time.", + "bullets": [ + "Configurable indicator inputs", + "Live re-execution per bar", + "Crosshair legend and chart markers" + ] + }, + { + "badge": "Workflows", + "title": "AI-powered workflows", + "description": "Build workflows on a canvas with AI agent blocks that make LLM-driven decisions. Integrate with Slack, Discord, GitHub, Gmail, and more - then route orders to Alpaca or Tradier.", + "bullets": [ + "AI agent blocks for autonomous analysis and decisions", + "Integrations with Slack, Discord, GitHub, Gmail, and more", + "Data, condition, loop, parallel, and trading action blocks" + ] + } + ] + }, + "integrations": { + "eyebrow": "Integrations", + "title": "LLM with more than just prompts.", + "bullets": [ + "Every integration becomes a tool your AI agents can call", + "Built-in blocks for messaging, databases, cloud storage, CRMs, and search", + "Custom MCP servers, skills, and tools you define yourself" + ], + "structuredData": { + "name": "TradingGoose integrations", + "description": "Third-party services, LLM providers, data sources, and tools that TradingGoose integrates with as callable workflow blocks." + } + } + }, + "blog": { + "pageTitle": "Blog", + "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}}\"", + "noMatchesDescription": "Try a different search term.", + "readTimeSuffix": "min read", + "viewArticle": "View Article", + "home": "Home", + "breadcrumbBlog": "Blog", + "articleSingular": "article", + "articlePlural": "articles" + }, + "privacy": { + "title": "Privacy Policy", + "description": "Privacy Policy for TradingGoose Studio, covering account data, workflows, connected services, analytics, billing, and retention practices.", + "lastUpdatedLabel": "Last Updated:", + "lastUpdated": "March 28, 2026" + }, + "workspace": { + "entry": { + "loading": "Loading workspace..." + }, + "switcher": { + "failedToCreateWorkspace": "Failed to create workspace", + "failedToRenameWorkspace": "Failed to rename workspace", + "failedToDeleteWorkspace": "Failed to delete workspace", + "roles": { + "owner": "Owner", + "admin": "Admin", + "member": "Member", + "read": "Read only" + }, + "workspaceLabel": "Workspace", + "noWorkspacesYet": "No workspaces yet.", + "noWorkspacesAvailable": "No workspaces available.", + "manage": "Manage", + "create": "Create", + "creating": "Creating..." + }, + "nav": { + "groups": { + "workspace": "Workspace", + "system": "System", + "more": "More" + }, + "workspace": { + "dashboard": "Dashboard", + "knowledge": "Knowledge", + "files": "Files", + "logs": "Logs" + }, + "more": { + "environment": "Environment Variables", + "apiKeys": "API Keys", + "integrations": "Integrations" + }, + "admin": { + "overview": "Overview", + "billing": "Billing", + "services": "Services", + "integrations": "Integrations", + "registration": "Registration" + }, + "systemAdmin": "System Admin" + }, + "defaults": { + "newWorkspaceName": "My Workspace", + "defaultLayoutName": "Default Layout", + "defaultWorkflowDescription": "Your first workflow - start building here!" + }, + "naming": { + "workspacePrefix": "Workspace", + "folderPrefix": "Folder", + "subfolderPrefix": "Subfolder" + }, + "dashboard": { + "title": "Dashboard", + "searchPlaceholder": "Search workspace content...", + "sections": { + "workspaces": "Workspaces", + "knowledgeBases": "Knowledge Bases", + "pages": "Pages", + "docs": "Docs" + }, + "emptySearch": "No matching content", + "pages": { + "logs": "Logs", + "knowledge": "Knowledge", + "templates": "Templates", + "docs": "Docs" + } + }, + "knowledge": { + "title": "Knowledge", + "searchPlaceholder": "Search knowledge bases...", + "sort": { + "lastUpdated": "Last Updated", + "newestFirst": "Newest First", + "oldestFirst": "Oldest First", + "nameAsc": "Name (A-Z)", + "nameDesc": "Name (Z-A)", + "mostDocuments": "Most Documents", + "leastDocuments": "Least Documents" + }, + "actions": { + "create": "Create", + "createTooltip": "Write permission required to create knowledge bases" + }, + "errors": { + "load": "Error loading knowledge bases: {{error}}", + "retry": "Try again" + }, + "emptyState": { + "createFirst": "Create your first knowledge base", + "withEditPermission": "Upload your documents to create a knowledge base for your agents.", + "withoutEditPermission": "Knowledge bases will appear here. Contact an admin to create knowledge bases.", + "buttonCreate": "Create Knowledge Base", + "buttonContactAdmin": "Contact Admin", + "noMatches": "No knowledge bases match your search." + } + }, + "logs": { + "title": { + "logs": "Logs", + "monitors": "Monitors", + "dashboard": "Dashboard" + }, + "searchPlaceholder": "Search logs...", + "live": "Live", + "monitorRequirement": "Please configure a workflow that uses an indicator as a trigger and an indicator that emits a trigger to add a monitor.", + "actions": { + "addMonitor": "Add monitor", + "refresh": "Refresh", + "refreshing": "Refreshing...", + "exportCsv": "Export CSV" + }, + "errors": { + "fetchLogs": "Failed to fetch logs" + }, + "dashboard": { + "title": "Dashboard", + "searchPlaceholder": "Search workflows...", + "failedToFetchExecutionHistory": "Failed to fetch execution history.", + "loadingExecutionHistory": "Loading execution history...", + "errorLoadingData": "Error loading execution history.", + "noExecutionHistory": "No execution history found.", + "noExecutionHistoryDescription": "Try adjusting your filters or time range.", + "refresh": "Refresh", + "refreshing": "Refreshing...", + "chart": { + "noData": "No data available.", + "toggleSeries": "Toggle series {{label}}" + }, + "filters": { + "title": "Filters", + "activeFilters": "Active filters", + "clearAll": "Clear all", + "suggestedFilters": "Suggested filters", + "textSearch": "Text search", + "searchPlaceholder": "Search logs...", + "filterOptionsPlaceholder": "No options found for {{title}}.", + "searchWorkflows": "Search workflows...", + "searchFolders": "Search folders...", + "searchOptions": "Search options...", + "loadingWorkflows": "Loading workflows...", + "loadingFolders": "Loading folders...", + "noWorkflows": "No workflows found.", + "noFolders": "No folders found.", + "noOptions": "No options found.", + "allWorkflows": "All workflows", + "selectedWorkflows": "{{count}} selected workflow{{plural}}", + "allFolders": "All folders", + "selectedFolders": "{{count}} selected folder{{plural}}", + "allTriggers": "All triggers", + "selectedTriggers": "{{count}} selected trigger{{plural}}", + "allTime": "All time", + "past30Minutes": "Past 30 minutes", + "pastHour": "Past hour", + "past6Hours": "Past 6 hours", + "past12Hours": "Past 12 hours", + "past24Hours": "Past 24 hours", + "past3Days": "Past 3 days", + "past7Days": "Past 7 days", + "past14Days": "Past 14 days", + "past30Days": "Past 30 days", + "manual": "Manual", + "api": "API", + "webhook": "Webhook", + "schedule": "Schedule", + "chat": "Chat", + "error": "Error", + "info": "Info", + "anyStatus": "Any status", + "level": "Level", + "workflow": "Workflow", + "folder": "Folder", + "trigger": "Trigger", + "timeline": "Timeline", + "retentionPolicy": "Log Retention Policy", + "retentionDescription": "Logs are automatically deleted after {{days}} days on this tier.", + "upgradePlan": "Upgrade Plan" + }, + "metrics": { + "totalExecutions": "Total executions", + "successRate": "Success rate", + "failedExecutions": "Failed executions", + "activeWorkflows": "Active workflows" + }, + "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}}\".", + "selectedSegment": "Selected segment", + "filteredTo": "Filtered to {{timestamp}}", + "selectedRangeMore": " (+{{count}} more segment{{plural}})", + "selectedRangeExecutions": "— {{count}} execution{{plural}}", + "clearFilter": "Clear filter", + "executions": "Executions", + "success": "Success", + "failures": "Failures", + "errorRate": "Error rate", + "duration": "Duration", + "columns": { + "time": "Time", + "status": "Status", + "trigger": "Trigger", + "cost": "Cost", + "workflow": "Workflow", + "output": "Output", + "duration": "Duration" + }, + "noExecutions": "No executions", + "loadingMore": "Loading more...", + "scrollToLoadMore": "Scroll to load more", + "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}}" + } + }, + "list": { + "headers": { + "time": "Time", + "status": "Status", + "workflow": "Workflow", + "cost": "Cost", + "trigger": "Trigger", + "duration": "Duration" + }, + "loading": "Loading logs...", + "loadingMore": "Loading more...", + "scrollToLoadMore": "Scroll to load more", + "noLogs": "No logs found", + "unknownWorkflow": "Unknown Workflow" + }, + "details": { + "title": "Log Details", + "previous": "Previous log", + "next": "Next log", + "close": "Close", + "selectLog": "Select a log to view details", + "timestamp": "Timestamp", + "workflow": "Workflow", + "executionId": "Execution ID", + "level": "Level", + "trigger": "Trigger", + "duration": "Duration", + "loading": "Loading details…", + "workflowState": "Workflow State", + "viewSnapshot": "View Snapshot", + "toolCalls": "Tool Calls", + "files": "Files", + "costBreakdown": "Cost Breakdown", + "baseExecution": "Base Execution:", + "modelInput": "Model Input:", + "modelOutput": "Model Output:", + "total": "Total:", + "tokens": "Tokens:", + "modelBreakdown": "Model Breakdown ({{count}})", + "input": "Input:", + "output": "Output:", + "totalCostNote": "Total cost includes a base execution charge of {{amount}} plus any model usage costs.", + "unknownSize": "Unknown size", + "unknownType": "Unknown type", + "unknownWorkflow": "Unknown", + "unknownLevel": "unknown", + "unknownValue": "Unknown", + "traceSpans": { + "workflowExecution": "Workflow execution", + "collapseAll": "Collapse all", + "expandAll": "Expand all", + "collapse": "Collapse", + "expand": "Expand", + "noTraceData": "No trace data available.", + "model": "Model", + "loadSkill": "Load skill", + "initialResponse": "Initial response", + "modelResponse": "Model response", + "modelGeneration": "Model generation", + "tokens": "{{count}} token{{plural}}", + "tokensUnavailable": "Tokens unavailable", + "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", + "inputSection": "Input", + "outputSection": "Output", + "errorSection": "Error", + "segmentTimingTooltip": "{{type}}{{nameSuffix}} took {{duration}} ms" + }, + "download": { + "downloading": "Downloading...", + "download": "Download" + } + }, + "monitors": { + "loading": "Loading monitors...", + "noConfigured": "No monitors configured.", + "status": "Status", + "provider": "Provider", + "auth": "Auth", + "listing": "Listing", + "indicator": "Indicator", + "workflow": "Workflow", + "actions": "Actions", + "active": "Active", + "paused": "Paused", + "configured": "Configured", + "missing": "Missing", + "edit": "Edit", + "pause": "Pause", + "activate": "Activate", + "remove": "Remove", + "searchProviders": "Search providers...", + "noProviders": "No providers found.", + "searchOptions": "Search options...", + "noOptions": "No options found.", + "searchIntervals": "Search intervals...", + "noIntervals": "No intervals found.", + "searchWorkflows": "Search workflows...", + "noWorkflows": "No workflows found.", + "searchIndicators": "Search indicators...", + "noIndicators": "No indicators found.", + "loadRequirements": "Loading monitor requirements...", + "noDeployedWorkflow": "No deployed workflow with indicator trigger is available, or no trigger-capable indicator exists.", + "failedToLoad": "Failed to load monitors.", + "failedToFetchLogs": "Failed to fetch monitor logs.", + "failedToSave": "Failed to save monitor.", + "activateDisabled": "A deployed workflow with a trigger-capable indicator is required before activating this monitor.", + "failedToUpdateState": "Failed to update monitor status.", + "failedToDelete": "Failed to delete monitor." + }, + "editor": { + "provider": "Provider", + "auth": "Auth", + "listing": "Listing", + "interval": "Interval", + "workflow": "Workflow", + "indicator": "Indicator", + "description": "Configure the monitor details.", + "feed": "Feed", + "selectInterval": "Select interval", + "selectWorkflow": "Select workflow", + "selectIndicator": "Select indicator", + "save": "Save Changes", + "create": "Create Monitor", + "saving": "Saving...", + "error": "Error", + "cancel": "Cancel", + "createTitle": "Create Monitor", + "editTitle": "Edit Monitor", + "selectProvider": "Select provider", + "searchProviders": "Search providers...", + "searchOptions": "Search options...", + "searchIntervals": "Search intervals...", + "searchWorkflows": "Search workflows...", + "searchIndicators": "Search indicators...", + "noProviders": "No providers found.", + "noOptions": "No options found.", + "noIntervals": "No intervals found.", + "noWorkflows": "No workflows found.", + "noIndicators": "No indicators found.", + "authConfigured": "Configured", + "authMissing": "Missing" + } + }, + "environment": { + "title": "Environment Variables", + "searchPlaceholder": "Search variables...", + "scope": { + "workspace": "Workspace", + "personal": "Personal" + }, + "create": { + "workspace": "Create Workspace Environment Variable", + "personal": "Create Personal Environment Variable" + }, + "emptyState": { + "workspace": { + "title": "No workspace variables yet", + "description": "Create one to start configuring." + }, + "personal": { + "title": "No personal variables yet", + "description": "Create one to start configuring." + } + }, + "searchEmpty": { + "workspace": "No workspace environment variables found matching \"{{query}}\".", + "personal": "No personal environment variables found matching \"{{query}}\"." + }, + "headers": { + "createdAt": "Created At", + "variable": "Variable", + "value": "Value", + "updatedAt": "Updated At", + "actions": "Actions" + }, + "labels": { + "untitledVariable": "Untitled variable", + "overriddenByWorkspaceVariable": "Overridden by workspace variable", + "revealValue": "Reveal value", + "hideValue": "Hide value", + "copyValue": "Copy environment value", + "save": "Save environment variable", + "cancel": "Cancel editing", + "edit": "Edit environment variable", + "delete": "Delete environment variable" + } + }, + "apiKeys": { + "title": "API Keys", + "cardTitle": "{{scope}} API Keys", + "searchPlaceholder": "Search keys...", + "scope": { + "workspace": "Workspace", + "personal": "Personal" + }, + "create": { + "workspace": "Create Workspace Key", + "personal": "Create Personal Key" + }, + "emptyState": { + "workspace": { + "title": "No workspace API keys yet", + "description": "Create one to start integrating right away.", + "button": "Create Key" + }, + "personal": { + "title": "No personal API keys yet", + "description": "Create one to start integrating right away.", + "button": "Create Key" + } + }, + "searchEmpty": "No {{scope}} API keys found matching \"{{query}}\".", + "headers": { + "createdAt": "Created At", + "name": "Name", + "key": "Key", + "lastUpdate": "Last Update", + "actions": "Actions" + }, + "labels": { + "never": "Never", + "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", + "cancelRename": "Cancel rename", + "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.", + "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.", + "unableToDetermineWorkspace": "Unable to determine workspace. Please refresh the page and try again.", + "workspacePermissions": "You need edit or admin access to manage workspace API keys." + }, + "dialogs": { + "createTitle": "Create {{scope}} API key", + "createNameLabel": "Name", + "createNamePlaceholder": "e.g., Production MCP Server", + "createButton": "Create 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?", + "deleteDescription": "This will immediately revoke access for any integrations using this key.", + "deletePrompt": "Type {{name}} to confirm.", + "deletePlaceholder": "API key name", + "cancel": "Cancel", + "deleteButton": "Delete Key", + "copyToClipboard": "Copy to clipboard" + } + }, + "integrations": { + "title": "Integrations", + "searchPlaceholder": "Search integrations...", + "successMessage": "Account connected successfully!", + "actionRequired": { + "title": "Action Required:", + "description": "Please connect your account to enable the requested features. The required service is highlighted below.", + "button": "Go to service" + }, + "otherServices": "Other Services", + "connect": "Connect", + "disconnect": "Disconnect", + "emptyState": { + "noConnectible": "No connectible integrations are configured.", + "noSearchMatches": "No services found matching \"{{query}}\"" + }, + "errors": { + "loadAvailability": "Failed to load provider availability", + "oauth": "Account connection failed. Please try again." + } + }, + "files": { + "title": "Files", + "searchPlaceholder": "Search files...", + "upload": { + "idle": "Upload File", + "uploading": "Uploading...", + "uploadingWithCount": "Uploading {{completed}}/{{total}}...", + "button": "Upload File" + }, + "headers": { + "name": "Name", + "size": "Size", + "uploaded": "Uploaded", + "actions": "Actions" + }, + "emptyState": { + "title": "No files uploaded yet", + "description": "Upload PDFs, docs, spreadsheets, or slides to power your workspace.", + "button": "Upload File" + }, + "searchEmpty": { + "title": "No files match your search", + "description": "Try a different keyword or clear the search input." + }, + "actions": { + "download": "Download", + "delete": "Delete" + }, + "deleteDialog": { + "title": "Delete file?", + "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", + "confirm": "Delete", + "deleting": "Deleting..." + } + }, + "userMenu": { + "accountDetail": "Account Detail", + "helpSupport": "Help & Support", + "serviceApiKeys": "Service API Keys", + "subscription": "Subscription", + "manageBilling": "Manage Billing", + "openingBilling": "Opening Billing…", + "teamManagement": "Team Management", + "singleSignOn": "Single Sign-On", + "logOut": "Log out", + "loggingOut": "Logging out…", + "billingPortalSelectOrganization": "Select an organization to manage billing.", + "billingPortalFailed": "Failed to open billing portal", + "themeLabel": "Theme: {{theme}}", + "languageLabel": "Language", + "themeOptions": { + "light": "Light", + "system": "System", + "dark": "Dark" + }, + "defaultAvatarAlt": "Default avatar" + }, + "settingsModal": { + "titles": { + "account": "Account Settings", + "service": "Service API Keys", + "subscription": "Subscription", + "team": "Team Management", + "sso": "Single Sign-On", + "help": "Help & Support" + }, + "common": { + "cancel": "Cancel" + }, + "account": { + "profilePicture": "Profile Picture", + "profilePictureAlt": "Profile picture", + "dropImage": "Drop an image or click to upload", + "imageHint": "PNG or JPG, max 5MB", + "profileDetails": "Profile Details", + "profileDetailsDescription": "Update your name and manage access.", + "fullName": "Full name", + "emailAddress": "Email address", + "emailHint": "Email changes are handled by support.", + "passwordReset": "Password reset", + "passwordResetDescription": "We’ll email you a secure link.", + "sendLink": "Send link", + "sending": "Sending…", + "saveName": "Save name", + "cancelEditingName": "Cancel editing name", + "editName": "Edit name", + "privacy": "Privacy", + "privacyDescription": "Manage how your data is collected.", + "telemetry": { + "label": "Allow anonymous telemetry", + "tooltipLabel": "Learn more about telemetry data collection", + "tooltipBody": "We collect anonymous data about feature usage, performance, and errors to improve the application.", + "body": "We use OpenTelemetry to collect anonymous usage data to improve TradingGoose. All data is collected in accordance with our privacy policy, and you can opt-out at any time. This setting applies to your account on all devices." + }, + "status": { + "profileSaved": "Profile saved.", + "nameRequired": "Please provide a name.", + "saveError": "Unable to save profile settings.", + "nameRequiredValidation": "Name is required", + "profilePictureUpdateError": "Failed to update profile picture", + "profilePictureRemoveError": "Failed to remove profile picture", + "unableToUpdateProfilePicture": "Unable to update profile picture.", + "failedUpdateName": "Failed to update name", + "unableToUpdateName": "Unable to update name. Please try again.", + "noEmail": "No email address found for this account.", + "passwordResetSent": "Password reset link sent to your inbox.", + "passwordResetFailed": "Unable to send password reset email." + } + }, + "help": { + "requestType": "Request", + "requestTypePlaceholder": "Select a request type", + "requestTypes": { + "bug": "Bug Report", + "feedback": "Feedback", + "feature_request": "Feature Request", + "other": "Other" + }, + "subject": "Subject", + "subjectPlaceholder": "Brief description of your request", + "message": "Message", + "messagePlaceholder": "Please provide details about your request...", + "attachments": "Attach Images (Optional)", + "dropImages": "Drop images here!", + "dropImagesBrowse": "Drop images here or click to browse", + "imageHint": "JPEG, PNG, WebP, GIF (max 20MB each)", + "uploadedImages": "Uploaded Images", + "cancel": "Cancel", + "submit": "Submit", + "submitting": "Submitting...", + "processing": "Processing images...", + "success": "Success", + "error": "Error", + "errorMessages": { + "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.", + "processing": "An error occurred while processing images. Please try again.", + "submitFailed": "Failed to submit help request", + "unknown": "An unknown error occurred" + } + }, + "service": { + "copilot": { + "title": "Copilot", + "description": "Generate keys for Copilot API access." + }, + "market": { + "title": "Market", + "description": "Generate keys for Market API access." + }, + "create": "Create", + "noKeys": "No API keys yet", + "generateSuccessTitle": "Your API key has been created", + "generateSuccessDescription": "This is the only time you will see your API key. Copy it now and store it securely.", + "copyToClipboard": "Copy to clipboard", + "deleteTitle": "Delete API key?", + "deleteDescription": "Deleting this API key will immediately revoke access for any integrations using it. This action cannot be undone.", + "cancel": "Cancel", + "delete": "Delete" + }, + "subscription": { + "titles": { + "manage": "Manage Subscription", + "restore": "Restore Subscription", + "upgrade": "Upgrade", + "increaseLimit": "Increase Limit", + "nextBillingDate": "Next Billing Date", + "usageNotifications": "Usage notifications", + "billingOwner": "Billing owner", + "organizationUsage": "Organization Usage", + "custom": "Custom", + "seats": "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", + "customPlan": "Contact your account team for billing tier and usage limit changes", + "teamMemberView": "Contact your team admin to increase limits" + }, + "limit": { + "save": "Save limit", + "edit": "Edit limit" + }, + "billingOwner": { + "title": "Billing owner", + "description": "Choose who owns billing for this workspace.", + "error": "Error", + "ownerLabel": "Owner", + "selectPlaceholder": "Select billing owner", + "organization": "Organization", + "billingNotice": "Changing the billing owner affects who pays for this workspace.", + "noActiveOrganization": "No active organization is available for billing ownership.", + "invalidSelection": "Invalid billing owner selection.", + "failedToUpdate": "Failed to update billing owner." + }, + "actions": { + "manage": "Manage", + "contact": "Contact", + "upgradeTo": "Upgrade to {{name}}" + }, + "badges": { + "resolvePayment": "Resolve Payment", + "addPaymentMethod": "Add Payment Method", + "activatePayg": "Activate PAYG", + "increaseLimit": "Increase Limit", + "manageBilling": "Manage Billing" + }, + "errors": { + "openBillingPortal": "Failed to open billing portal", + "unknown": "Unknown error occurred", + "selectOrganization": "Select an organization to manage billing.", + "activatePayg": "Failed to activate PAYG", + "loadBillingData": "Failed to load billing data." + } + }, + "team": { + "error": "Error", + "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.", + "howBillingWorks": "How this team billing works", + "usageNote": "Note:", + "usageNoteBody": "Users can only be part of one organization at a time. They must leave their current organization before joining another.", + "teamId": "Team ID:", + "created": "Created:", + "yourRole": "Your Role:", + "upgradeToCreateTeam": "Upgrade To Create a Team", + "upgradeToCreateTeamDescription": "Upgrade to an organization tier to create a team workspace.", + "openSubscriptionSettings": "Open Subscription Settings", + "createYourTeamWorkspace": "Create Your Team Workspace", + "createYourTeamWorkspaceDescription": "Create an organization to collaborate with your team.", + "teamName": "Team Name", + "teamNamePlaceholder": "My Team", + "teamUrl": "Team URL", + "teamSlugPlaceholder": "my-team", + "createTeamWorkspace": "Create Team Workspace", + "noTeamSubscriptionFound": "No Team Subscription Found", + "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", + "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.", + "currentSeats": "Current seats:", + "newSeats": "New seats:", + "monthlyCostChange": "Monthly cost change:", + "loading": "Loading...", + "reactivateSubscription": "To update seats, go to Subscription > Manage > Keep Subscription to reactivate", + "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?", + "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.", + "cancel": "Cancel", + "remove": "Remove", + "inviteUnavailableMessage": "An active organization subscription is required before you can invite team members.", + "yourself": "yourself", + "thisMember": "this member", + "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.", + "confirm": "Update Seats" + }, + "billing": { + "title": "Workspace billing", + "description": "Assign workspaces to the organization or the owner.", + "organizationBillingRequired": "Organization billing is required to manage workspace billing.", + "organizationBilledTitle": "Billed to organization", + "organizationBilledEmpty": "No workspaces are billed to the organization.", + "availableOwnerBilledTitle": "Owner-billed workspaces", + "availableOwnerBilledEmpty": "No owner-billed workspaces are available.", + "returnToOwner": "Return to owner billing", + "billToOrganization": "Bill to organization", + "organization": "Organization", + "ownerBilling": "Owner billing", + "ownerLabel": "Owner:" + }, + "members": { + "title": "Team members", + "empty": "No team members yet.", + "sharedUsage": "Shared usage: ${{amount}}", + "pending": "Pending", + "billing": "Billing", + "usage": "Usage", + "sharedPool": "Shared pool", + "unknown": "Unknown", + "removeMemberTooltip": "Remove member", + "cancelling": "Cancelling...", + "cancelInvitationTooltip": "Cancel invitation", + "leaveOrganization": "Leave organization" + }, + "invitation": { + "title": "Invite team members", + "description": "Invite people to join your organization and choose workspace access.", + "emailPlaceholder": "name@example.com", + "hideWorkspaces": "Hide workspaces", + "addWorkspaces": "Add workspaces", + "invite": "Invite", + "noSeats": "No seats available", + "unavailable": "Invite unavailable", + "workspaceAccess": "Workspace access", + "optional": "Optional", + "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}}.", + "invalidEmail": "Please enter a valid email address.", + "permissions": { + "read": { + "label": "Read", + "description": "Can view workspace content." + }, + "write": { + "label": "Write", + "description": "Can edit workspace content." + }, + "admin": { + "label": "Admin", + "description": "Can manage workspace settings." + } + } + } + }, + "sso": { + "providerStatus": "Single Sign-On Provider", + "issuerUrl": "Issuer URL", + "providerId": "Provider ID", + "callbackUrl": "Callback URL", + "callbackUrlHelp": "Use this callback URL in your identity provider settings.", + "providerType": "Provider Type", + "providerTypeDescriptions": { + "oidc": "OpenID Connect (Okta, Azure AD, Auth0, etc.)", + "saml": "Security Assertion Markup Language (ADFS, Shibboleth, etc.)" + }, + "selectProviderHelp": "Select a pre-configured provider ID from the trusted providers list.", + "issuerUrlHelp": "Use the issuer URL provided by your identity provider.", + "providerIdPlaceholder": "Select a provider ID", + "issuerUrlPlaceholder": "Enter Issuer URL", + "domain": "Domain", + "domainPlaceholder": "Enter Domain", + "clientId": "Client ID", + "clientIdPlaceholder": "Enter Client ID", + "clientSecret": "Client Secret", + "clientSecretPlaceholder": "Enter Client Secret", + "scopes": "Scopes", + "selectProviderId": "Select a provider ID", + "copyCallbackUrl": "Copy callback URL", + "showClientSecret": "Show client secret", + "hideClientSecret": "Hide client secret", + "scopesPlaceholder": "openid,profile,email", + "scopesDescription": "Comma-separated list of OIDC scopes to request.", + "entryPoint": "Entry Point URL", + "entryPointPlaceholder": "Enter Entry Point URL", + "entryPointDescription": "Provide the SAML entry point URL from your identity provider.", + "certificate": "Identity Provider Certificate", + "certificatePlaceholder": "-----BEGIN CERTIFICATE-----", + "certificateDescription": "Paste the certificate provided by your identity provider.", + "advancedOptions": "Advanced SAML Options", + "audience": "Audience (Entity ID)", + "audiencePlaceholder": "Enter Audience", + "audienceDescription": "The SAML audience restriction, optional and defaults to the app URL.", + "callbackUrlOverride": "Callback URL Override", + "callbackUrlPlaceholder": "Enter Callback URL", + "callbackUrlDescription": "Custom SAML callback URL, optional and auto-generated if empty.", + "requireSignedAssertions": "Require signed SAML assertions", + "metadataXml": "Identity Provider Metadata XML", + "metadataPlaceholder": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\">\n ...\n</md:EntityDescriptor>", + "metadataDescription": "Paste the complete IDP metadata XML from your identity provider for advanced configuration.", + "loadingProviders": "Loading providers...", + "noProviders": "No providers found.", + "save": "Save Changes", + "create": "Configure SSO Provider", + "configureProvider": "Configure SSO Provider", + "saving": "Saving...", + "configuring": "Configuring...", + "selectOrganization": "You must be part of an organization to configure Single Sign-On.", + "onlyAdmins": "Only organization owners and admins can configure Single Sign-On settings.", + "disabledTier": "Single Sign-On is not enabled for this billing tier.", + "providerLoadError": "Failed to load SSO provider configuration", + "providerError": "Failed to configure SSO provider", + "reloadError": "Failed to reload SSO providers", + "validation": { + "fieldRequired": "{{field}} is required.", + "providerIdRequired": "Provider ID is required.", + "providerIdPattern": "Use letters, numbers, and dashes only.", + "issuerUrlRequired": "Issuer URL is required.", + "issuerUrlHttps": "Issuer URL must use HTTPS.", + "issuerUrlValid": "Enter a valid issuer URL like https://your-identity-provider.com/oauth2/default", + "domainRequired": "Domain is required.", + "domainNoProtocol": "Do not include protocol (https://).", + "domainValid": "Enter a valid domain like your-domain.identityprovider.com", + "clientIdRequired": "Client ID is required.", + "clientSecretRequired": "Client Secret is required.", + "scopesRequired": "Scopes are required for OIDC providers", + "entryPointRequired": "Entry Point URL is required for SAML providers", + "certificateRequired": "Certificate is required.", + "providerLoadFailed": "Failed to load SSO providers", + "providerLoadFailedGeneric": "Failed to load SSO provider configuration" + } + } + }, + "widgets": { + "selector": { + "categories": { + "list": "Lists", + "editor": "Editor", + "utility": "Utility" + }, + "selectWidget": "Select widget", + "widgetSelectionUnavailable": "Widget selection unavailable" + }, + "titles": { + "empty": "Empty Surface", + "data_chart": "Market Data", + "workflow_list": "Workflows", + "editor_workflow": "Workflow Editor", + "workflow_chat": "Workflow Chat", + "workflow_console": "Workflow Console", + "copilot": "Copilot", + "list_indicator": "Indicators", + "list_mcp": "MCP Servers", + "editor_indicator": "Indicator Editor", + "editor_mcp": "MCP Editor", + "list_custom_tool": "Custom Tools", + "editor_custom_tool": "Custom Tool Editor", + "list_skill": "Skills", + "editor_skill": "Skill Editor", + "workflow_variables": "Workflow Variables", + "watchlist": "Watchlist" + }, + "empty": { + "noWidgetSelected": "No widget selected", + "emptyWidget": "Empty Widget", + "noWidgetDescription": "Pick a widget from the gallery to start using this panel.", + "emptyWidgetDescription": "This widget is currently empty, choose another widget to continue.", + "chooseWidget": "Choose Widget", + "surfaceTitle": "Empty Surface", + "surfaceDescription": "Placeholder state shown when the panel does not have a widget assigned." + }, + "workflowDropdown": { + "selectWorkspaceFirst": "Select a workspace first.", + "unableToLoad": "Unable to load workflows", + "workflowSelectionUnavailable": "Workflow selection unavailable", + "selectWorkflow": "Select workflow", + "searchPlaceholder": "Search workflows...", + "failedToLoad": "Failed to load workflows", + "retry": "Retry", + "loading": "Loading workflows...", + "noWorkflowsAvailable": "No workflows available yet.", + "noWorkflowsFound": "No workflows found.", + "untitledWorkflow": "Untitled workflow" + }, + "mcpDropdown": { + "selectWorkspaceFirst": "Select a workspace first.", + "unableToLoad": "Unable to load MCP servers", + "mcpSelectionUnavailable": "MCP selection unavailable", + "selectMcpServer": "Select MCP server", + "searchPlaceholder": "Search servers...", + "failedToLoad": "Failed to load MCP servers", + "retry": "Retry", + "loading": "Loading MCP servers...", + "noServersAvailable": "No MCP servers available yet.", + "noServersFound": "No servers found.", + "unnamedServer": "Unnamed server" + }, + "console": { + "selectWorkspace": "Select a workspace to load workflows.", + "noWorkflows": "No workflows available in this workspace.", + "filters": "Filters", + "status": "Status", + "error": "Error", + "info": "Info", + "blocks": "Blocks", + "sortByTime": "Sort by time", + "structuredView": "Structured view", + "toggleStructuredView": "Toggle structured view", + "wrapText": "Wrap text", + "toggleWrapText": "Toggle wrap text", + "downloadConsoleCsv": "Download console CSV", + "downloadCsv": "Download CSV", + "clearConsole": "Clear console", + "noResults": "No results found for \"{{query}}\"" + }, + "mcpEditor": { + "selectWorkspaceToEdit": "Select a workspace to edit MCP servers.", + "selectServerToEdit": "Select an MCP server to edit.", + "saveDraftHint": "Save this draft to enable connection tests, tool refresh, and canonical reload.", + "failedToLoadMcpServers": "Failed to load MCP servers.", + "tools": "Tools", + "saveRequired": "Save required", + "noToolsDiscovered": "No tools discovered yet.", + "saveThisServerToRefreshAndInspectDiscoveredMcpTools": "Save this server to refresh and inspect discovered MCP tools.", + "refreshTools": "Refresh tools", + "testConnection": "Test connection", + "resetForm": "Reset form", + "saveServer": "Save server", + "clearSelection": "Clear selection", + "selectServer": "Select server", + "serverNameRequired": "Server name is required.", + "failedToRefreshMcpServer": "Failed to refresh MCP server.", + "failedToSaveMcpServer": "Failed to save MCP server.", + "toolCount": "{{count}} total", + "loading": "Loading...", + "connected": "Connected", + "error": "Error", + "draft": "Draft", + "disconnected": "Disconnected", + "unnamedServer": "Unnamed server", + "updated": "Updated {{time}}", + "toolsRefreshed": "Tools refreshed {{time}}", + "lastConnected": "Last connected {{time}}", + "lastError": "Last error" + }, + "triggerList": { + "coreTriggers": "Core Triggers", + "integrationTriggers": "Integration Triggers", + "searchPlaceholder": "Search triggers", + "openTriggerList": "Click to Add Trigger", + "close": "Close", + "noResults": "No results found for \"{{query}}\"" + }, + "workflowToolbar": { + "selectWorkspace": "Select a workspace to browse blocks", + "blocks": "Blocks", + "tools": "Tools", + "triggers": "Triggers", + "special": "Special", + "browseLabel": "Browse {{label}}", + "searchPlaceholder": "Search {{label}}...", + "noResults": "No {{label}} found." + }, + "workflowLabels": { + "systemPrompt": "System Prompt", + "userPrompt": "User Prompt", + "model": "Model", + "temperature": "Temperature", + "apiKey": "API Key", + "skills": "Skills", + "tools": "Tools", + "responseFormat": "Response Format", + "reasoningEffort": "Reasoning Effort", + "verbosity": "Verbosity", + "configured": "Configured", + "value": "Value", + "items": "Items", + "fields": "Fields", + "object": "Object", + "block": "Block", + "type": "Type", + "none": "None", + "noValuesToDisplay": "No values to display.", + "error": "Error", + "if": "if", + "else": "else", + "elseIf": "else if", + "addSkill": "Add Skill", + "searchSkills": "Search skills...", + "chooseModel": "Choose model", + "lite": "Lite", + "anthropic": "Anthropic", + "openai": "OpenAI", + "nextStep": "Next Step", + "locked": "Locked", + "deployed": "Deployed", + "deployedWithVersion": "Deployed (v{{version}})", + "notDeployed": "Not Deployed", + "disabled": "Disabled", + "removeSkill": "Remove {{name}}", + "currentWorkflow": "Current Workflow", + "currentSkill": "Current Skill", + "currentTool": "Current Tool", + "currentIndicator": "Current Indicator", + "currentMcpServer": "Current MCP Server", + "workflows": "Workflows", + "customTools": "Custom Tools", + "indicators": "Indicators", + "mcpServers": "MCP Servers", + "allWorkflows": "All workflows" + }, + "workflowEditor": { + "previewInspector": "Preview Inspector", + "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}}`.", + "saveName": "Save name", + "renameNode": "Rename node", + "loopTypeLabel": "Loop Type", + "parallelTypeLabel": "Parallel Type", + "selectType": "Select type", + "forLoop": "For Loop", + "forEachLoop": "For Each", + "whileLoop": "While Loop", + "doWhileLoop": "Do While Loop", + "parallelCount": "Parallel Count", + "parallelEach": "Parallel Each", + "loopIterations": "Loop Iterations", + "parallelExecutions": "Parallel Executions", + "whileCondition": "While Condition", + "collectionItems": "Collection Items", + "parallelItems": "Parallel Items", + "enterValueBetween": "Enter a value between 1 and {{max}}", + "hideAdditionalFields": "Hide additional fields", + "showAdditionalFields": "Show additional fields", + "additionalFields": "Additional fields", + "triggerNoEditableFields": "This trigger has no editable fields in the panel.", + "blockNoEditableFields": "No editable fields for this block.", + "requiredField": "This field is required", + "invalidJson": "Invalid JSON", + "unknownInputType": "Unknown input type: {{type}}", + "loop": "Loop", + "parallel": "Parallel", + "start": "Start", + "end": "End" + }, + "apiKey": { + "apiKey": "API Key", + "apiKeyType": "API Key Type", + "apiKeyName": "API Key Name", + "personal": "Personal", + "workspace": "Workspace", + "selectApiKey": "Select API Key", + "myApiKey": "My API Key", + "ownerIsBilledForUsage": "Owner is billed for usage", + "keyOwnerIsBilled": "Key owner is billed", + "createNew": "Create new", + "loadingApiKeys": "Loading API keys...", + "selectAnApiKey": "Select an API key", + "noApiKeysAvailable": "No API keys available", + "createNewApiKey": "Create new API key", + "workspaceAccess": "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again.", + "personalAccess": "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again.", + "apiKeyHasBeenCreated": "Your API key has been created", + "onlyTimeYouWillSeeYourApiKey": "This is the only time you will see your API key.", + "copyItNowAndStoreItSecurely": "Copy it now and store it securely.", + "copyToClipboard": "Copy to clipboard", + "enterName": "Please enter a name for the API key", + "failedToCreate": "Failed to create API key", + "create": "Create", + "creating": "Creating...", + "cancel": "Cancel", + "workspaceLabel": "Workspace", + "personalLabel": "Personal" + }, + "entityEditor": { + "undo": "Undo", + "redo": "Redo" + }, + "pairColor": { + "selectionUnavailable": "Color selection unavailable", + "selectWidgetColor": "Select widget color", + "unlinked": "Unlinked", + "red": "Red", + "orange": "Orange", + "blue": "Blue", + "green": "Green", + "purple": "Purple" + }, + "deployment": { + "adminPermissionsRequiredToDeployWorkflows": "Admin permissions required to deploy workflows", + "deploying": "Deploying...", + "workflowChangesDetected": "Workflow changes detected", + "deploymentSettings": "Deployment Settings", + "deployWorkflow": "Deploy Workflow", + "deployApi": "Deploy API", + "needsRedeployment": "Needs Redeployment", + "deployWorkflowTitle": "Deploy Workflow", + "active": "active", + "close": "Close", + "deploymentError": "Deployment Error", + "expandSidebar": "Expand sidebar", + "collapseSidebar": "Collapse sidebar", + "selectSharedDeploymentApiKeyInBillingBeforeDeployingThisApiTrigger": "Select a shared deployment API key in Billing before deploying this API trigger.", + "thisApiTriggerUsesTheSharedDeploymentApiKey": "This API trigger uses the shared deployment API key {displayKey}.", + "thisApiTriggerUsesTheSharedDeploymentApiKeySelectedInBilling": "This API trigger uses the shared deployment API key selected in Billing.", + "thisApiTriggerWillUseTheSharedDeploymentApiKeyCurrentlySelectedInBilling": "This API trigger will use the shared deployment API key currently selected in Billing." + }, + "workflowCreateMenu": { + "createButtonTooltip": "Create folder or workflow", + "selectWorkspaceTooltip": "Select a workspace to create workflows", + "createWorkflow": "New workflow", + "createFolder": "New folder", + "importWorkflow": "Import workflow", + "creating": "Creating...", + "importing": "Importing..." + } + }, + "layoutTabs": { + "createNewLayout": "Create new layout" + } + } +} diff --git a/apps/tradinggoose/i18n/messages/es.json b/apps/tradinggoose/i18n/messages/es.json new file mode 100644 index 000000000..7d650b717 --- /dev/null +++ b/apps/tradinggoose/i18n/messages/es.json @@ -0,0 +1,1800 @@ +{ + "meta": { + "landing": { + "title": "TradingGoose - Plataforma visual de flujos de trabajo para trading con LLM | Open Source", + "description": "Plataforma de código abierto para trading técnico impulsado por LLM. Indicadores personalizados en PineTS, monitores de mercado en vivo y flujos de trabajo de agentes de IA activados por señales del mercado.", + "openGraphTitle": "TradingGoose - Plataforma visual de flujos de trabajo para trading con LLM", + "openGraphDescription": "Plataforma de código abierto para trading técnico impulsado por LLM. Indicadores personalizados en PineTS, monitores de mercado en vivo y flujos de trabajo de agentes de IA activados por señales del mercado.", + "seo": { + "keywords": "flujos de trabajo de trading con IA, agentes de trading con LLM, automatización técnica de trading, indicadores de trading personalizados, indicadores PineTS, creador visual de flujos de trabajo de trading, automatización de señales de trading, flujo de trabajo de datos de mercado, plataforma de backtesting, plataforma de trading de código abierto, trading algorítmico, asistente de trading con IA", + "socialPreviewAlt": "Vista previa social de TradingGoose", + "llmContentType": "plataforma visual de flujos de trabajo para trading, indicadores personalizados, flujos de trabajo de agentes de IA para mercados", + "llmUseCases": "ejecución de operaciones basada en señales, rebalanceo de cartera, alertas de indicadores, backtesting de estrategias, análisis del sentimiento del mercado, paneles de trading personalizados", + "llmIntegrations": "OpenAI, Anthropic, Google Gemini, xAI, Mistral, Perplexity, Ollama, proveedores personalizados de datos de mercado", + "llmPricing": "Consulta los precios alojados en tradinggoose.ai" + } + }, + "blog": { + "title": "Blog | TradingGoose", + "description": "Artículos sobre automatización de trading, diseño de flujos de trabajo y estrategias más inteligentes." + }, + "privacy": { + "title": "Política de Privacidad | TradingGoose", + "description": "Política de privacidad de TradingGoose Studio, que cubre datos de cuentas, flujos de trabajo, servicios conectados, analítica, facturación y retención." + } + }, + "nav": { + "docs": "Documentación", + "blog": "Blog", + "login": "Iniciar sesión", + "menu": "Menú", + "homeLabel": "Inicio", + "languageLabel": "Idioma" + }, + "registration": { + "open": { + "primary": "Empezar", + "auth": "Registrarse" + }, + "waitlist": { + "primary": "Unirse a la lista de espera", + "auth": "Unirse a la lista de espera" + }, + "disabled": { + "primary": "Próximamente", + "auth": null + } + }, + "auth": { + "common": { + "email": "Correo electrónico", + "password": "Contraseña", + "fullName": "Nombre completo", + "workEmail": "Correo de trabajo", + "enterYourEmail": "Introduce tu correo electrónico", + "enterYourPassword": "Introduce tu contraseña", + "enterYourName": "Introduce tu nombre", + "enterYourWorkEmail": "Introduce tu correo de trabajo", + "forgotPassword": "¿Olvidaste tu contraseña?", + "showPassword": "Mostrar contraseña", + "hidePassword": "Ocultar contraseña", + "continueWith": "O continuar con", + "or": "O", + "signIn": "Iniciar sesión", + "signUp": "Registrarse", + "alreadyHaveAccount": "¿Ya tienes una cuenta?", + "dontHaveAccount": "¿No tienes una cuenta?", + "termsLeadSigningIn": "Al iniciar sesión, aceptas nuestros", + "termsLeadCreatingAccount": "Al crear una cuenta, aceptas nuestros", + "termsOfService": "Términos de servicio", + "privacyPolicy": "Política de privacidad", + "and": "y", + "returnHome": "Volver al inicio", + "backToLogin": "Volver al inicio de sesión", + "backToSignup": "Volver al registro", + "verifyEmail": "Verificar correo", + "signInWithEmail": "Iniciar sesión con correo", + "signInWithSso": "Iniciar sesión con SSO", + "continueWithSso": "Continuar con SSO", + "requestAccess": "Solicitar acceso" + }, + "error": { + "eyebrow": "Error de autenticación", + "codeLabel": "Código de error", + "supportPrefix": "Si esto sigue ocurriendo, contacta con", + "supportLinkLabel": "soporte", + "supportSuffix": "e incluye el código de error.", + "default": { + "title": "Algo salió mal", + "description": "No pudimos completar esa solicitud de autenticación. Vuelve a intentarlo iniciando sesión." + }, + "groups": { + "accountCreation": { + "title": "No pudimos crear tu cuenta", + "description": "Tu solicitud de registro no se completó. Intenta de nuevo desde el formulario de registro o inicia sesión si este correo ya está registrado." + }, + "accountExists": { + "title": "La cuenta ya existe", + "description": "Ya hay una cuenta registrada con este correo. Inicia sesión en lugar de crear una cuenta nueva." + }, + "emailVerification": { + "title": "Verifica tu correo para continuar", + "description": "Tu cuenta existe, pero todavía falta completar la verificación del correo." + }, + "invalidCallback": { + "title": "Este enlace de inicio de sesión no es válido", + "description": "La devolución de autenticación no era válida. Vuelve a iniciar el flujo de inicio de sesión." + }, + "invalidToken": { + "title": "Este enlace de autenticación no es válido", + "description": "No se pudo verificar el enlace o token. Vuelve a iniciar el flujo de autenticación." + }, + "expiredToken": { + "title": "Este enlace de autenticación ha caducado", + "description": "El enlace o token ha caducado. Vuelve a iniciar el flujo de autenticación." + }, + "sessionCreation": { + "title": "No pudimos iniciar tu sesión", + "description": "La autenticación se completó, pero no se pudo crear la sesión. Vuelve a iniciar sesión." + }, + "sessionRestore": { + "title": "No pudimos restaurar tu sesión", + "description": "No se pudo cargar tu sesión. Vuelve a iniciar sesión." + }, + "sessionExpired": { + "title": "Tu sesión ha caducado", + "description": "Inicia sesión de nuevo para continuar." + }, + "userInfo": { + "title": "No pudimos completar tu inicio de sesión", + "description": "No pudimos leer tu identidad del proveedor. Intenta iniciar sesión de nuevo." + }, + "providerUnavailable": { + "title": "Este proveedor de inicio de sesión no está disponible", + "description": "El proveedor de inicio de sesión solicitado no está configurado ahora mismo." + }, + "linkedAccount": { + "title": "Este proveedor ya está vinculado", + "description": "Ese proveedor de inicio de sesión ya está conectado a otra cuenta. Usa otro método para continuar." + }, + "waitlistLimited": { + "title": "El registro está limitado", + "description": "El registro está limitado a correos aprobados de la lista de espera." + }, + "registrationDisabled": { + "title": "El registro está deshabilitado actualmente", + "description": "El registro está deshabilitado actualmente." + } + } + }, + "disabled": { + "title": "Registro cerrado", + "description": "El registro está deshabilitado actualmente." + }, + "note": { + "waitlistApprovedEmail": "Usa el mismo correo aprobado de la lista de espera para cualquier método de inicio de sesión." + }, + "social": { + "github": "GitHub", + "google": "Google", + "connecting": "Conectando..." + }, + "verify": { + "eyebrow": "Verificación", + "pendingTitle": "Verifica tu correo", + "verifiedTitle": "¡Correo verificado!", + "verifiedDescription": "Tu correo ha sido verificado. Redirigiendo al panel...", + "disabledDescription": "La verificación por correo está deshabilitada. Redirigiendo al panel...", + "codeSent": "Se ha enviado un código de verificación a {{email}}", + "developmentDescription": "Modo de desarrollo: revisa los registros de la consola para ver el código de verificación", + "missingServiceDescription": "Error: la verificación por correo está activada pero no hay un servicio de correo configurado", + "instructionsWithService": "Introduce 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.", + "instructionsWithoutService": "Introduce el código de 6 dígitos para verificar tu cuenta.", + "verifyButton": "Verificar correo", + "verifyingButton": "Verificando...", + "resendPrompt": "¿No recibiste un código?", + "resendIn": "Reenviar en {{countdown}}s", + "resendButton": "Reenviar", + "yourEmail": "tu correo", + "errors": { + "invalid": "El código de verificación no es válido. Compruébalo e inténtalo de nuevo.", + "expired": "El código de verificación ha caducado. Solicita uno nuevo.", + "generic": "La verificación falló. Comprueba tu código e inténtalo de nuevo.", + "attempts": "Demasiados intentos fallidos. Solicita un código nuevo.", + "resendFailed": "No se pudo reenviar el código de verificación. Vuelve a intentarlo más tarde." + } + }, + "login": { + "eyebrow": "Iniciar sesión", + "title": "Bienvenido de nuevo", + "description": "Introduce tus credenciales", + "submit": "Iniciar sesión", + "submitting": "Iniciando sesión...", + "divider": "O continuar con", + "resetDialog": { + "title": "Restablecer contraseña", + "description": "Introduce tu correo electrónico y te enviaremos un enlace para restablecer tu contraseña si tu cuenta existe.", + "emailLabel": "Correo electrónico", + "emailPlaceholder": "Introduce tu correo electrónico", + "emailRequired": "Introduce tu correo electrónico.", + "emailInvalid": "Introduce una dirección de correo válida.", + "success": "Enlace para restablecer la contraseña enviado a tu correo", + "error": "No se pudo solicitar el restablecimiento de contraseña", + "submit": "Enviar enlace de restablecimiento", + "submitting": "Enviando..." + }, + "validation": { + "emailRequired": "El correo electrónico es obligatorio.", + "emailInvalid": "Introduce una dirección de correo válida.", + "passwordRequired": "La contraseña es obligatoria.", + "passwordEmpty": "La contraseña no puede estar vacía." + }, + "errors": { + "sessionExpired": "Tu sesión caducó. Vuelve a iniciar sesión.", + "emailSignInDisabled": "El inicio de sesión con correo está deshabilitado actualmente.", + "invalidCredentials": "Correo o contraseña inválidos. Inténtalo de nuevo.", + "noAccount": "No se encontró una cuenta con este correo. Regístrate primero.", + "missingCredentials": "Introduce el correo y la contraseña.", + "emailPasswordDisabled": "El inicio de sesión con correo y contraseña está deshabilitado.", + "failedToCreateSession": "No se pudo crear la sesión. Inténtalo más tarde.", + "tooManyAttempts": "Demasiados intentos de inicio de sesión. Inténtalo más tarde o restablece tu contraseña.", + "accountLocked": "Tu cuenta ha sido bloqueada por seguridad. Restablece tu contraseña.", + "network": "Error de red. Revisa tu conexión e inténtalo de nuevo.", + "rateLimit": "Demasiadas solicitudes. Espera un momento antes de volver a intentarlo.", + "unableToSignIn": "No se pudo iniciar sesión.", + "unableToSignInNow": "No se puede iniciar sesión ahora mismo. Inténtalo de nuevo." + } + }, + "signup": { + "eyebrow": "Registrarse", + "title": "Crear una cuenta", + "descriptionOpen": "Crea una cuenta o inicia sesión", + "descriptionWaitlist": "Se requiere acceso aprobado de la lista de espera", + "submit": "Crear cuenta", + "submitting": "Creando cuenta...", + "divider": "O continuar con", + "nameTitle": "El nombre solo puede contener letras, espacios, guiones y apóstrofes", + "validation": { + "emailRequired": "El correo electrónico es obligatorio.", + "emailInvalid": "Introduce una dirección de correo válida.", + "nameRequired": "El nombre es obligatorio.", + "nameEmpty": "El nombre no puede estar vacío.", + "nameCharacters": "El nombre solo puede contener letras, espacios, guiones y apóstrofes.", + "nameSpaces": "El nombre no puede contener espacios consecutivos.", + "nameTooLong": "El nombre se truncará a 100 caracteres. Acorta tu nombre.", + "passwordMinLength": "La contraseña debe tener al menos 8 caracteres.", + "passwordUppercase": "La contraseña debe incluir al menos una letra mayúscula.", + "passwordLowercase": "La contraseña debe incluir al menos una letra minúscula.", + "passwordNumber": "La contraseña debe incluir al menos un número.", + "passwordSpecial": "La contraseña debe incluir al menos un carácter especial." + }, + "errors": { + "failedToCreateAccount": "No se pudo crear la cuenta", + "accountExists": "Ya existe una cuenta con este correo. Inicia sesión en su lugar.", + "emailSignupDisabled": "El registro con correo está deshabilitado actualmente.", + "signupNotEnabled": "El registro con correo y contraseña no está habilitado.", + "waitlistRequired": "Este correo aún no está aprobado para registrarse. Únete primero a la lista de espera.", + "invalidEmail": "Introduce una dirección de correo válida.", + "passwordTooShort": "La contraseña debe tener al menos 8 caracteres.", + "passwordTooLong": "La contraseña debe tener menos de 128 caracteres.", + "network": "Error de red. Revisa tu conexión e inténtalo de nuevo.", + "rateLimit": "Demasiadas solicitudes. Espera un momento antes de volver a intentarlo." + } + }, + "waitlist": { + "eyebrow": "Lista de espera", + "title": "Solicita acceso a TradingGoose", + "description": "Únete a la cola para obtener acceso a la plataforma. Si tu correo ya está aprobado, puedes pasar directamente al registro desde aquí.", + "helperText": "Usa el correo que quieres que revisen para el acceso a la plataforma.", + "submit": "Solicitar acceso", + "submitting": "Enviando...", + "pending": "Estás en la lista de espera. Revisaremos tu solicitud y te avisaremos cuando el acceso esté disponible.", + "approvedPrefix": "Tu correo está aprobado. Continúa con", + "signedUpPrefix": "Este correo ya tiene acceso. Continúa con", + "rejected": "Esta solicitud de lista de espera no está aprobada para el acceso.", + "signUpLink": "registrarte", + "loginLink": "iniciar sesión", + "validation": { + "emailRequired": "El correo electrónico es obligatorio.", + "emailInvalid": "Introduce una dirección de correo válida." + } + }, + "sso": { + "eyebrow": "SSO", + "title": "Iniciar sesión con SSO", + "description": "Introduce tu correo de trabajo para continuar", + "submit": "Continuar con SSO", + "submitting": "Redirigiendo al proveedor SSO...", + "divider": "O", + "emailButton": "Iniciar sesión con correo", + "validation": { + "emailRequired": "El correo electrónico es obligatorio.", + "emailInvalid": "Introduce una dirección de correo válida." + }, + "errors": { + "accountNotFound": "No se encontró una cuenta. Contacta con tu administrador para configurar el acceso SSO.", + "ssoFailed": "Falló la autenticación SSO. Inténtalo de nuevo.", + "providerNotConfigured": "El proveedor SSO no está configurado correctamente.", + "invalidEmailDomain": "El dominio del correo no está configurado para SSO. Contacta con tu administrador.", + "network": "Error de red. Revisa tu conexión e inténtalo de nuevo.", + "rateLimit": "Demasiadas solicitudes. Espera un momento antes de volver a intentarlo.", + "ssoDisabled": "La autenticación SSO está deshabilitada. Usa otro método de inicio de sesión.", + "failed": "No se pudo iniciar sesión con SSO. Inténtalo de nuevo." + } + } + }, + "localeNames": { + "en": "English", + "es": "Español", + "zh-CN": "简体中文" + }, + "landing": { + "hero": { + "statusBadges": { + "disabled": "¡Honk! TradingGoose-Studio llegará pronto", + "waitlist": "¡Honk! Presentamos TradingGoose-Studio", + "open": "¡Honk! TradingGoose-Studio ya está aquí" + }, + "leadWords": [ + "Construye", + "Prueba", + "Ejecuta" + ], + "highlightWords": [ + "análisis de trading", + "detección de señales", + "evaluación de riesgo" + ], + "titleConnector": "tu", + "suffix": "con TradingGoose", + "description": "Conecta tus propios proveedores de datos, escribe indicadores personalizados para vigilar precios de mercado y llévalos a flujos de trabajo que disparen operaciones, ventas, compras o cualquier acción que definas.", + "featureBadges": [ + "Flujos de trabajo con agentes de IA", + "Indicadores personalizados", + "Trae tus propios datos", + "Integraciones" + ], + "learnMore": "Saber más" + }, + "cta": { + "title": "Deja que los agentes de IA trabajen tu estrategia de trading.", + "description": "Mira lo que la comunidad está construyendo con TradingGoose.", + "joinDiscord": "Unirse a Discord", + "placeholder": "tu@correo.com", + "subscribe": "Recibir novedades", + "subscribing": "Suscribiendo...", + "success": "Suscrito. Revisa tu bandeja de entrada.", + "error": "Algo salió mal." + }, + "footer": { + "description": "Plataforma de flujos de trabajo de IA para trading técnico con LLM", + "copyright": "© {{year}} {{brand}}. Creado para flujos de trabajo visuales de trading.", + "links": { + "docs": "Documentación", + "blog": "Blog", + "widgets": "Widgets", + "indicators": "Indicadores", + "blocks": "Bloques", + "tools": "Herramientas", + "changelog": "Registro de cambios", + "privacy": "Política de privacidad", + "licenses": "Licencias", + "terms": "Términos de servicio" + }, + "social": { + "discord": "Discord", + "github": "GitHub" + }, + "hoverText": "HONK!" + }, + "preview": { + "shell": { + "headerAriaLabel": "Encabezado del widget", + "widgetLabel": "Widget" + }, + "layout": { + "headerAriaLabel": "Encabezado del widget", + "sizeLabel": "Tamaño del widget" + }, + "indicatorDropdown": { + "placeholder": "Seleccionar indicadores", + "tooltip": "Seleccionar indicadores", + "searchPlaceholder": "Buscar indicadores...", + "emptyWithQuery": "No se encontraron indicadores.", + "emptyWithoutQuery": "Aún no hay indicadores disponibles." + }, + "market": { + "indicatorUnavailableError": "El indicador no está disponible en esta demostración." + }, + "workflow": { + "zoomOut": "Alejar vista previa del flujo de trabajo", + "zoomIn": "Acercar vista previa del flujo de trabajo", + "selectorAriaLabel": "Selector de vista previa del flujo de trabajo", + "demos": { + "signalBriefing": "Resumen de señales", + "investmentDebate": "Debate de inversión", + "riskRouting": "Enrutamiento de riesgos" + } + } + }, + "howItWorks": { + "eyebrow": "Cómo funciona", + "title": "De los datos a la decisión", + "description": "Conecta tus propias fuentes de datos, monitorea los mercados con indicadores personalizados, deja que los agentes de IA analicen lo que importa y activa flujos de trabajo que actúen en tu nombre.", + "processes": [ + { + "title": "Conecta tus datos", + "description": "Conecta cualquier proveedor de datos de mercado y transmite precios en vivo al espacio de trabajo." + }, + { + "title": "Monitorea con indicadores", + "description": "Escribe indicadores PineTS personalizados que vigilen las condiciones que te interesan." + }, + { + "title": "Analiza con agentes de IA", + "description": "Deja que los bloques de agentes impulsados por LLM evalúen señales, evalúen riesgos y tomen decisiones de forma autónoma." + }, + { + "title": "Activar workflows", + "description": "Cuando se activa una señal, inicia un workflow para operar, alertar, registrar o cualquier otra acción que definas." + } + ] + }, + "monitorSection": { + "eyebrow": "Monitores en vivo", + "title": "Indicadores que activan workflows", + "description": "Configura monitores que observan tus indicadores en datos de mercado en vivo. Cuando se activa una señal, un workflow se ejecuta automáticamente: coloca órdenes, envía alertas, registra resultados o cualquier otra acción.", + "bullets": [ + "Conecta cualquier proveedor de datos en streaming con tus propias credenciales.", + "Elige un indicador y un intervalo para monitorear por activo.", + "Dirige los triggers a cualquier workflow desplegado." + ], + "tableHeaders": { + "listing": "Activo", + "indicator": "Indicador", + "workflow": "Workflow", + "status": "Estado" + }, + "statuses": { + "pending": "Pendiente", + "running": "Ejecutando", + "success": "Éxito", + "failed": "Fallido" + }, + "indicatorOptions": [ + { + "name": "RSI < 30", + "color": "#8b5cf6" + }, + { + "name": "MACD Cross", + "color": "#14b8a6" + }, + { + "name": "EMA 21/50", + "color": "#f59e0b" + }, + { + "name": "Supertrend", + "color": "#ef4444" + }, + { + "name": "BB Squeeze", + "color": "#3b82f6" + }, + { + "name": "Pico de volumen", + "color": "#10b981" + } + ], + "workflowOptions": [ + { + "name": "Análisis de sentimiento", + "color": "#6366f1" + }, + { + "name": "Evaluación de riesgos", + "color": "#f59e0b" + }, + { + "name": "Rebalanceo de cartera", + "color": "#22c55e" + }, + { + "name": "Verificación de informes de resultados", + "color": "#3b82f6" + }, + { + "name": "Escaneo de redes sociales", + "color": "#8b5cf6" + }, + { + "name": "Análisis de volatilidad", + "color": "#ef4444" + }, + { + "name": "Correlación sectorial", + "color": "#14b8a6" + } + ] + }, + "features": { + "eyebrow": "Funcionalidades", + "title": "Tu espacio de trabajo, a tu manera", + "description": "Diseños, gráficos y flujos de trabajo: cada uno diseñado para funcionar solo o en conjunto.", + "rows": [ + { + "badge": "Espacio de trabajo", + "title": "Diseños de widgets", + "description": "Divide el espacio de trabajo para colocar widgets uno al lado del otro o apilados. Guarda y cambia entre diseños nombrados por espacio de trabajo.", + "bullets": [ + "División recursiva", + "Diseños guardados por espacio de trabajo", + "Menú de acciones del widget compartido" + ] + }, + { + "badge": "Gráficos", + "title": "Indicadores y datos en vivo", + "description": "Indicadores integrados y un editor PineTS para escribir indicadores personalizados. Conecta tu propio proveedor de datos y monitorea precios en tiempo real.", + "bullets": [ + "Entradas de indicador configurables", + "Re-ejecución en vivo por barra", + "Leyenda de referencia cruzada y marcadores en el gráfico" + ] + }, + { + "badge": "Flujos de trabajo", + "title": "Flujos de trabajo impulsados por IA", + "description": "Construye flujos de trabajo en un lienzo con bloques de agente de IA que toman decisiones basadas en LLM. Integra con Slack, Discord, GitHub, Gmail y más, luego enruta órdenes a Alpaca o Tradier.", + "bullets": [ + "Bloques de agente de IA para análisis y decisiones autónomas", + "Integraciones con Slack, Discord, GitHub, Gmail y más", + "Bloques de datos, condición, bucle, paralelo y acciones de trading" + ] + } + ] + }, + "integrations": { + "eyebrow": "Integraciones", + "title": "LLM con más que solo indicaciones.", + "bullets": [ + "Cada integración se convierte en una herramienta que tus agentes de IA pueden invocar", + "Bloques integrados para mensajería, bases de datos, almacenamiento en la nube, CRMs y búsqueda", + "Servidores MCP, habilidades y herramientas personalizados que tú mismo defines" + ], + "structuredData": { + "name": "Integraciones de TradingGoose", + "description": "Servicios de terceros, proveedores de LLM, fuentes de datos y herramientas con las que TradingGoose se integra como bloques de flujo de trabajo invocables." + } + } + }, + "blog": { + "pageTitle": "Blog", + "pageDescription": "Ideas sobre automatización de trading, diseño de flujos de trabajo y 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}}\"", + "noMatchesDescription": "Prueba con otro término de búsqueda.", + "readTimeSuffix": "min de lectura", + "viewArticle": "Ver artículo", + "home": "Inicio", + "breadcrumbBlog": "Blog", + "articleSingular": "artículo", + "articlePlural": "artículos" + }, + "privacy": { + "title": "Política de Privacidad", + "description": "Política de privacidad de TradingGoose Studio, que cubre datos de cuentas, flujos de trabajo, servicios conectados, analítica, facturación y retención.", + "lastUpdatedLabel": "Última actualización:", + "lastUpdated": "28 de marzo de 2026" + }, + "workspace": { + "entry": { + "loading": "Cargando el espacio de trabajo..." + }, + "switcher": { + "failedToCreateWorkspace": "No se pudo crear el espacio de trabajo", + "failedToRenameWorkspace": "No se pudo cambiar el nombre del espacio de trabajo", + "failedToDeleteWorkspace": "No se pudo eliminar el espacio de trabajo", + "roles": { + "owner": "Propietario", + "admin": "Administrador", + "member": "Miembro", + "read": "Solo lectura" + }, + "workspaceLabel": "Espacio de trabajo", + "noWorkspacesYet": "Todavía no hay espacios de trabajo.", + "noWorkspacesAvailable": "No hay espacios de trabajo disponibles.", + "manage": "Administrar", + "create": "Crear", + "creating": "Creando..." + }, + "nav": { + "groups": { + "workspace": "Espacio de trabajo", + "system": "Sistema", + "more": "Más" + }, + "workspace": { + "dashboard": "Panel", + "knowledge": "Conocimiento", + "files": "Archivos", + "logs": "Registros" + }, + "more": { + "environment": "Variables de entorno", + "apiKeys": "Claves API", + "integrations": "Integraciones" + }, + "admin": { + "overview": "Resumen", + "billing": "Facturación", + "services": "Servicios", + "integrations": "Integraciones", + "registration": "Registro" + }, + "systemAdmin": "Administrador del sistema" + }, + "defaults": { + "newWorkspaceName": "Mi espacio de trabajo", + "defaultLayoutName": "Diseño predeterminado", + "defaultWorkflowDescription": "Tu primer flujo de trabajo: empieza a construir aquí." + }, + "naming": { + "workspacePrefix": "Espacio de trabajo", + "folderPrefix": "Carpeta", + "subfolderPrefix": "Subcarpeta" + }, + "dashboard": { + "title": "Panel", + "searchPlaceholder": "Buscar contenido del espacio de trabajo...", + "sections": { + "workspaces": "Espacios de trabajo", + "knowledgeBases": "Bases de conocimiento", + "pages": "Páginas", + "docs": "Documentos" + }, + "emptySearch": "No hay contenido coincidente", + "pages": { + "logs": "Registros", + "knowledge": "Conocimiento", + "templates": "Plantillas", + "docs": "Documentos" + } + }, + "knowledge": { + "title": "Conocimiento", + "searchPlaceholder": "Buscar bases de conocimiento...", + "sort": { + "lastUpdated": "Última actualización", + "newestFirst": "Más reciente primero", + "oldestFirst": "Más antiguo primero", + "nameAsc": "Nombre (A-Z)", + "nameDesc": "Nombre (Z-A)", + "mostDocuments": "Más documentos", + "leastDocuments": "Menos documentos" + }, + "actions": { + "create": "Crear", + "createTooltip": "Se requiere permiso de escritura para crear bases de conocimiento" + }, + "errors": { + "load": "Error al cargar las bases de conocimiento: {{error}}", + "retry": "Intentar de nuevo" + }, + "emptyState": { + "createFirst": "Crea tu primera base de conocimiento", + "withEditPermission": "Sube tus documentos para crear una base de conocimiento para tus agentes.", + "withoutEditPermission": "Las bases de conocimiento aparecerán aquí. Contacta a un administrador para crearlas.", + "buttonCreate": "Crear base de conocimiento", + "buttonContactAdmin": "Contactar al administrador", + "noMatches": "Ninguna base de conocimiento coincide con tu búsqueda." + } + }, + "logs": { + "title": { + "logs": "Registros", + "monitors": "Monitores", + "dashboard": "Panel" + }, + "searchPlaceholder": "Buscar registros...", + "live": "En vivo", + "monitorRequirement": "Configura un flujo de trabajo que use un indicador como disparador y un indicador que emita un disparador para agregar un monitor.", + "actions": { + "addMonitor": "Agregar monitor", + "refresh": "Actualizar", + "refreshing": "Actualizando...", + "exportCsv": "Exportar CSV" + }, + "errors": { + "fetchLogs": "No se pudieron cargar los registros" + }, + "dashboard": { + "title": "Panel", + "searchPlaceholder": "Buscar flujos de trabajo...", + "failedToFetchExecutionHistory": "Error al obtener el historial de ejecuciones.", + "loadingExecutionHistory": "Cargando historial de ejecuciones...", + "errorLoadingData": "Error al cargar el historial de ejecuciones.", + "noExecutionHistory": "No se encontró historial de ejecuciones.", + "noExecutionHistoryDescription": "Intenta ajustar tus filtros o el rango de tiempo.", + "refresh": "Actualizar", + "refreshing": "Actualizando...", + "chart": { + "noData": "No hay datos disponibles.", + "toggleSeries": "Alternar serie {{label}}" + }, + "filters": { + "title": "Filtros", + "activeFilters": "Filtros activos", + "clearAll": "Limpiar todo", + "suggestedFilters": "Filtros sugeridos", + "textSearch": "Búsqueda de texto", + "searchPlaceholder": "Buscar registros...", + "filterOptionsPlaceholder": "No se encontraron opciones para {{title}}.", + "searchWorkflows": "Buscar flujos de trabajo...", + "searchFolders": "Buscar carpetas...", + "searchOptions": "Buscar opciones...", + "loadingWorkflows": "Cargando flujos de trabajo...", + "loadingFolders": "Cargando carpetas...", + "noWorkflows": "No se encontraron flujos de trabajo.", + "noFolders": "No se encontraron carpetas.", + "noOptions": "No se encontraron opciones.", + "allWorkflows": "Todos los workflows", + "selectedWorkflows": "{{count}} workflow seleccionado{{plural}}", + "allFolders": "Todas las carpetas", + "selectedFolders": "{{count}} carpeta seleccionada{{plural}}", + "allTriggers": "Todos los triggers", + "selectedTriggers": "{{count}} trigger seleccionado{{plural}}", + "allTime": "Todo el tiempo", + "past30Minutes": "Últimos 30 minutos", + "pastHour": "Última hora", + "past6Hours": "Últimas 6 horas", + "past12Hours": "Últimas 12 horas", + "past24Hours": "Últimas 24 horas", + "past3Days": "Últimos 3 días", + "past7Days": "Últimos 7 días", + "past14Days": "Últimos 14 días", + "past30Days": "Últimos 30 días", + "manual": "Manual", + "api": "API", + "webhook": "Webhook", + "schedule": "Programado", + "chat": "Chat", + "error": "Error", + "info": "Información", + "anyStatus": "Cualquier estado", + "level": "Nivel", + "workflow": "Workflow", + "folder": "Carpeta", + "trigger": "Trigger", + "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.", + "upgradePlan": "Actualizar plan" + }, + "metrics": { + "totalExecutions": "Ejecuciones totales", + "successRate": "Tasa de éxito", + "failedExecutions": "Ejecuciones fallidas", + "activeWorkflows": "Workflows activos" + }, + "workflows": { + "title": "Workflows", + "legend": "Cada celda representa aproximadamente {{duration}} del rango seleccionado. Haga clic en una celda para filtrar los detalles.", + "count": "{{count}} workflow", + "countPlural": "{{count}} workflows", + "filteredFrom": " (filtrados de {{count}})", + "noMatches": "No se encontraron workflows que coincidan con \"{{query}}\".", + "selectedSegment": "Segmento seleccionado", + "filteredTo": "Filtrado a {{timestamp}}", + "selectedRangeMore": " (+{{count}} segmento más{{plural}})", + "selectedRangeExecutions": "— {{count}} ejecución{{plural}}", + "clearFilter": "Limpiar filtro", + "executions": "Ejecuciones", + "success": "Éxito", + "failures": "Fallos", + "errorRate": "Tasa de error", + "duration": "Duración", + "columns": { + "time": "Hora", + "status": "Estado", + "trigger": "Desencadenante", + "cost": "Costo", + "workflow": "Flujo de trabajo", + "output": "Salida", + "duration": "Duración" + }, + "noExecutions": "Sin ejecuciones", + "loadingMore": "Cargando más...", + "scrollToLoadMore": "Desplázate para cargar más", + "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}}" + } + }, + "list": { + "headers": { + "time": "Hora", + "status": "Estado", + "workflow": "Flujo de trabajo", + "cost": "Costo", + "trigger": "Desencadenante", + "duration": "Duración" + }, + "loading": "Cargando registros...", + "loadingMore": "Cargando más...", + "scrollToLoadMore": "Desplázate para cargar más", + "noLogs": "No se encontraron registros", + "unknownWorkflow": "Workflow desconocido" + }, + "details": { + "title": "Detalles del registro", + "previous": "Registro anterior", + "next": "Siguiente registro", + "close": "Cerrar", + "selectLog": "Selecciona un registro para ver los detalles", + "timestamp": "Marca de tiempo", + "workflow": "Workflow", + "executionId": "Execution ID", + "level": "Nivel", + "trigger": "Trigger", + "duration": "Duración", + "loading": "Cargando detalles…", + "workflowState": "Estado del Workflow", + "viewSnapshot": "Ver instantánea", + "toolCalls": "Tool Calls", + "files": "Archivos", + "costBreakdown": "Desglose de costos", + "baseExecution": "Base Execution:", + "modelInput": "Entrada del modelo:", + "modelOutput": "Salida del modelo:", + "total": "Total:", + "tokens": "Tokens:", + "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.", + "unknownSize": "Tamaño desconocido", + "unknownType": "Tipo desconocido", + "unknownWorkflow": "Desconocido", + "unknownLevel": "desconocido", + "unknownValue": "Desconocido", + "traceSpans": { + "workflowExecution": "Ejecución del flujo de trabajo", + "collapseAll": "Contraer todo", + "expandAll": "Expandir todo", + "collapse": "Contraer", + "expand": "Expandir", + "noTraceData": "No hay datos de traza disponibles.", + "model": "Modelo", + "loadSkill": "Cargar habilidad", + "initialResponse": "Respuesta inicial", + "modelResponse": "Respuesta del modelo", + "modelGeneration": "Generación del modelo", + "tokens": "{{count}} token{{plural}}", + "tokensUnavailable": "Tokens no disponibles", + "tokensInOut": "{{input}} entrada / {{output}} salida", + "tokensTotal": "{{count}} token{{plural}} total", + "tokensTotalSuffix": " ({{count}} total)", + "input": "Entrada", + "output": "Salida", + "total": "Total", + "start": "Inicio", + "plusMs": "+{{ms}} ms", + "betweenBlocks": "Intervalo de {{ms}} ms", + "inputSection": "Entrada", + "outputSection": "Salida", + "errorSection": "Error", + "segmentTimingTooltip": "{{type}}{{nameSuffix}} tomó {{duration}} ms" + }, + "download": { + "downloading": "Descargando...", + "download": "Descargar" + } + }, + "monitors": { + "loading": "Cargando monitores...", + "noConfigured": "No hay monitores configurados.", + "status": "Estado", + "provider": "Proveedor", + "auth": "Autenticación", + "listing": "Listado", + "indicator": "Indicador", + "workflow": "Workflow", + "actions": "Acciones", + "active": "Activo", + "paused": "Pausado", + "configured": "Configurado", + "missing": "Faltante", + "edit": "Editar", + "pause": "Pausar", + "activate": "Activar", + "remove": "Eliminar", + "searchProviders": "Buscar proveedores...", + "noProviders": "No se encontraron proveedores.", + "searchOptions": "Buscar opciones...", + "noOptions": "No se encontraron opciones.", + "searchIntervals": "Buscar intervalos...", + "noIntervals": "No se encontraron intervalos.", + "searchWorkflows": "Buscar flujos de trabajo...", + "noWorkflows": "No se encontraron flujos de trabajo.", + "searchIndicators": "Buscar indicadores...", + "noIndicators": "No se encontraron indicadores.", + "loadRequirements": "Cargando requisitos del monitor...", + "noDeployedWorkflow": "No hay ningún flujo de trabajo desplegado con un disparador de indicador disponible, o no existe ningún indicador con capacidad de disparo.", + "failedToLoad": "No se pudieron cargar los monitores.", + "failedToFetchLogs": "No se pudieron obtener los registros del monitor.", + "failedToSave": "No se pudo guardar el monitor.", + "activateDisabled": "Se requiere un flujo de trabajo desplegado con un indicador con capacidad de disparo antes de activar este monitor.", + "failedToUpdateState": "No se pudo actualizar el estado del monitor.", + "failedToDelete": "No se pudo eliminar el monitor." + }, + "editor": { + "provider": "Proveedor", + "auth": "Autenticación", + "listing": "Listado", + "interval": "Intervalo", + "workflow": "Workflow", + "indicator": "Indicator", + "description": "Configure los detalles del monitor.", + "feed": "Fuente", + "selectInterval": "Seleccionar intervalo", + "selectWorkflow": "Seleccionar workflow", + "selectIndicator": "Seleccionar indicator", + "save": "Guardar cambios", + "create": "Crear monitor", + "saving": "Guardando...", + "error": "Error", + "cancel": "Cancelar", + "createTitle": "Crear monitor", + "editTitle": "Editar monitor", + "selectProvider": "Seleccionar proveedor", + "searchProviders": "Buscar proveedores...", + "searchOptions": "Buscar opciones...", + "searchIntervals": "Buscar intervalos...", + "searchWorkflows": "Buscar workflows...", + "searchIndicators": "Buscar indicators...", + "noProviders": "No se encontraron proveedores.", + "noOptions": "No se encontraron opciones.", + "noIntervals": "No se encontraron intervalos.", + "noWorkflows": "No se encontraron flujos de trabajo.", + "noIndicators": "No se encontraron indicadores.", + "authConfigured": "Configurado", + "authMissing": "Ausente" + } + }, + "environment": { + "title": "Variables de entorno", + "searchPlaceholder": "Buscar variables...", + "scope": { + "workspace": "Espacio de trabajo", + "personal": "Personal" + }, + "create": { + "workspace": "Crear variable de entorno del espacio de trabajo", + "personal": "Crear variable de entorno personal" + }, + "emptyState": { + "workspace": { + "title": "Aún no hay variables del espacio de trabajo", + "description": "Crea una para empezar a configurar." + }, + "personal": { + "title": "Aún no hay variables personales", + "description": "Crea una para empezar a configurar." + } + }, + "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}}\"." + }, + "headers": { + "createdAt": "Creado el", + "variable": "Variable", + "value": "Valor", + "updatedAt": "Actualizado el", + "actions": "Acciones" + }, + "labels": { + "untitledVariable": "Variable sin título", + "overriddenByWorkspaceVariable": "Reemplazada por variable del espacio de trabajo", + "revealValue": "Revelar valor", + "hideValue": "Ocultar valor", + "copyValue": "Copiar valor de variable de entorno", + "save": "Guardar variable de entorno", + "cancel": "Cancelar edición", + "edit": "Editar variable de entorno", + "delete": "Eliminar variable de entorno" + } + }, + "apiKeys": { + "title": "Claves API", + "cardTitle": "Claves API de {{scope}}", + "searchPlaceholder": "Buscar claves...", + "scope": { + "workspace": "Espacio de trabajo", + "personal": "Personal" + }, + "create": { + "workspace": "Crear clave de espacio de trabajo", + "personal": "Crear clave personal" + }, + "emptyState": { + "workspace": { + "title": "Aún no hay claves API del espacio de trabajo", + "description": "Crea una para empezar a integrar de inmediato.", + "button": "Crear clave" + }, + "personal": { + "title": "Aún no hay claves API personales", + "description": "Crea una para empezar a integrar de inmediato.", + "button": "Crear clave" + } + }, + "searchEmpty": "No se encontraron claves API de {{scope}} que coincidan con \"{{query}}\".", + "headers": { + "createdAt": "Fecha de creación", + "name": "Nombre", + "key": "Clave", + "lastUpdate": "Última actualización", + "actions": "Acciones" + }, + "labels": { + "never": "Nunca", + "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}}", + "cancelRename": "Cancelar cambio de nombre", + "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.", + "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.", + "unableToDetermineWorkspace": "No se puede determinar el espacio de trabajo. Actualiza la página e inténtalo de nuevo.", + "workspacePermissions": "Necesitas acceso de edición o administrador para gestionar las claves API del espacio de trabajo." + }, + "dialogs": { + "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}}", + "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}}?", + "deleteDescription": "Esto revocará inmediatamente el acceso de cualquier integración que use esta clave.", + "deletePrompt": "Escribe {{name}} para confirmar.", + "deletePlaceholder": "Nombre de la clave API", + "cancel": "Cancelar", + "deleteButton": "Eliminar clave", + "copyToClipboard": "Copiar al portapapeles" + } + }, + "integrations": { + "title": "Integraciones", + "searchPlaceholder": "Buscar integraciones...", + "successMessage": "¡Cuenta conectada con éxito!", + "actionRequired": { + "title": "Acción requerida:", + "description": "Conecta tu cuenta para habilitar las funciones solicitadas. El servicio requerido está resaltado a continuación.", + "button": "Ir al servicio" + }, + "otherServices": "Otros servicios", + "connect": "Conectar", + "disconnect": "Desconectar", + "emptyState": { + "noConnectible": "No hay integraciones conectables configuradas.", + "noSearchMatches": "No se encontraron servicios que coincidan con \"{{query}}\"" + }, + "errors": { + "loadAvailability": "Error al cargar la disponibilidad del proveedor", + "oauth": "Error al conectar la cuenta. Inténtalo de nuevo." + } + }, + "files": { + "title": "Archivos", + "searchPlaceholder": "Buscar archivos...", + "upload": { + "idle": "Subir archivo", + "uploading": "Subiendo...", + "uploadingWithCount": "Subiendo {{completed}}/{{total}}...", + "button": "Subir archivo" + }, + "headers": { + "name": "Nombre", + "size": "Tamaño", + "uploaded": "Subido", + "actions": "Acciones" + }, + "emptyState": { + "title": "Aún no se han subido archivos", + "description": "Suba PDF, documentos, hojas de cálculo o presentaciones para potenciar su espacio de trabajo.", + "button": "Subir archivo" + }, + "searchEmpty": { + "title": "Ningún archivo coincide con su búsqueda", + "description": "Pruebe con otra palabra clave o borre la entrada de búsqueda." + }, + "actions": { + "download": "Descargar", + "delete": "Eliminar" + }, + "deleteDialog": { + "title": "¿Eliminar archivo?", + "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", + "confirm": "Eliminar", + "deleting": "Eliminando..." + } + }, + "userMenu": { + "accountDetail": "Detalles de la cuenta", + "helpSupport": "Ayuda y soporte", + "serviceApiKeys": "Claves de API de servicio", + "subscription": "Suscripción", + "manageBilling": "Gestionar facturación", + "openingBilling": "Abriendo facturación…", + "teamManagement": "Gestión de equipo", + "singleSignOn": "Inicio de sesión único", + "logOut": "Cerrar sesión", + "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}}", + "languageLabel": "Idioma", + "themeOptions": { + "light": "Claro", + "system": "Sistema", + "dark": "Oscuro" + }, + "defaultAvatarAlt": "Avatar predeterminado" + }, + "settingsModal": { + "titles": { + "account": "Configuración de cuenta", + "service": "Claves de API de servicio", + "subscription": "Suscripción", + "team": "Gestión de equipo", + "sso": "Inicio de sesión único", + "help": "Ayuda y soporte" + }, + "common": { + "cancel": "Cancelar" + }, + "account": { + "profilePicture": "Foto de perfil", + "profilePictureAlt": "Foto de perfil", + "dropImage": "Arrastre una imagen o haga clic para subir", + "imageHint": "PNG o JPG, máximo 5 MB", + "profileDetails": "Detalles del perfil", + "profileDetailsDescription": "Actualice su nombre y administre el acceso.", + "fullName": "Nombre completo", + "emailAddress": "Dirección de correo electrónico", + "emailHint": "Los cambios de correo electrónico son gestionados por el soporte.", + "passwordReset": "Restablecimiento de contraseña", + "passwordResetDescription": "Le enviaremos un enlace seguro por correo electrónico.", + "sendLink": "Enviar enlace", + "sending": "Enviando…", + "saveName": "Guardar nombre", + "cancelEditingName": "Cancelar edición del nombre", + "editName": "Editar nombre", + "privacy": "Privacidad", + "privacyDescription": "Gestione cómo se recopilan sus datos.", + "telemetry": { + "label": "Permitir telemetría anónima", + "tooltipLabel": "Obtenga más información sobre la recopilación de datos de telemetría", + "tooltipBody": "Recopilamos datos anónimos sobre el uso de funciones, el rendimiento y los errores para mejorar la aplicación.", + "body": "Usamos OpenTelemetry para recopilar datos anónimos de uso y mejorar TradingGoose. Todos los datos se recopilan de acuerdo con nuestra política de privacidad, y puede optar por no participar en cualquier momento. Esta configuración se aplica a su cuenta en todos los dispositivos." + }, + "status": { + "profileSaved": "Perfil guardado.", + "nameRequired": "Proporcione un nombre.", + "saveError": "No se pueden guardar los ajustes del perfil.", + "nameRequiredValidation": "El nombre es obligatorio", + "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.", + "failedUpdateName": "No se pudo actualizar el nombre", + "unableToUpdateName": "No se puede actualizar el nombre. Inténtalo de nuevo.", + "noEmail": "No se encontró una dirección de correo electrónico para esta cuenta.", + "passwordResetSent": "Enlace de restablecimiento de contraseña enviado a tu bandeja de entrada.", + "passwordResetFailed": "No se pudo enviar el correo de restablecimiento de contraseña." + } + }, + "help": { + "requestType": "Solicitud", + "requestTypePlaceholder": "Selecciona un tipo de solicitud", + "requestTypes": { + "bug": "Reporte de error", + "feedback": "Comentarios", + "feature_request": "Solicitud de función", + "other": "Otro" + }, + "subject": "Asunto", + "subjectPlaceholder": "Breve descripción de tu solicitud", + "message": "Mensaje", + "messagePlaceholder": "Proporciona detalles sobre tu solicitud...", + "attachments": "Adjuntar imágenes (opcional)", + "dropImages": "Arrastra imágenes aquí", + "dropImagesBrowse": "Arrastra imágenes aquí o haz clic para examinar", + "imageHint": "JPEG, PNG, WebP, GIF (máx. 20MB cada uno)", + "uploadedImages": "Imágenes subidas", + "cancel": "Cancelar", + "submit": "Enviar", + "submitting": "Enviando...", + "processing": "Procesando imágenes...", + "success": "Éxito", + "error": "Error", + "errorMessages": { + "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.", + "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" + } + }, + "service": { + "copilot": { + "title": "Copilot", + "description": "Genere claves para el acceso a la API de Copilot." + }, + "market": { + "title": "Market", + "description": "Genere claves para el acceso a la API de Market." + }, + "create": "Crear", + "noKeys": "Aún no hay claves de API", + "generateSuccessTitle": "Su clave de API ha sido creada", + "generateSuccessDescription": "Esta es la única vez que verá su clave de API. Cópiela ahora y guárdela de forma segura.", + "copyToClipboard": "Copiar al portapapeles", + "deleteTitle": "¿Eliminar clave de API?", + "deleteDescription": "Eliminar esta clave de API revocará inmediatamente el acceso de cualquier integración que la use. Esta acción no se puede deshacer.", + "cancel": "Cancelar", + "delete": "Eliminar" + }, + "subscription": { + "titles": { + "manage": "Gestionar suscripción", + "restore": "Restaurar suscripción", + "upgrade": "Actualizar", + "increaseLimit": "Aumentar límite", + "nextBillingDate": "Próxima fecha de facturación", + "usageNotifications": "Notificaciones de uso", + "billingOwner": "Propietario de facturación", + "organizationUsage": "Uso de la organización", + "custom": "Personalizado", + "seats": "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.", + "customPlan": "Contacte a su equipo de cuenta para cambios de nivel de facturación y límites de uso.", + "teamMemberView": "Contacte al administrador de su equipo para aumentar los límites." + }, + "limit": { + "save": "Guardar límite", + "edit": "Editar límite" + }, + "billingOwner": { + "title": "Propietario de facturación", + "description": "Elija quién es el propietario de la facturación para este espacio de trabajo.", + "error": "Error", + "ownerLabel": "Propietario", + "selectPlaceholder": "Seleccione el propietario de facturación", + "organization": "Organización", + "billingNotice": "Cambiar el propietario de facturación afecta a quién paga por este espacio de trabajo.", + "noActiveOrganization": "No hay ninguna organización activa disponible para la propiedad de facturación.", + "invalidSelection": "Selección de propietario de facturación no válida.", + "failedToUpdate": "No se pudo actualizar el propietario de facturación." + }, + "actions": { + "manage": "Gestionar", + "contact": "Contactar", + "upgradeTo": "Actualizar a {{name}}" + }, + "badges": { + "resolvePayment": "Resolver pago", + "addPaymentMethod": "Agregar método de pago", + "activatePayg": "Activar PAYG", + "increaseLimit": "Aumentar límite", + "manageBilling": "Gestionar facturación" + }, + "errors": { + "openBillingPortal": "Error al abrir el portal de facturación", + "unknown": "Ocurrió un error desconocido", + "selectOrganization": "Selecciona una organización para gestionar la facturación.", + "activatePayg": "Error al activar PAYG", + "loadBillingData": "Error al cargar los datos de facturación." + } + }, + "team": { + "error": "Error", + "defaultTeamName": "Equipo de {{name}}", + "billingHowWorksSeatCost": "El costo de {{seats}} asiento{{plural}} es de ${{amount}} al mes.", + "billingHowWorksUsageTracked": "El uso se rastrea en todos los workspaces 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.", + "howBillingWorks": "Cómo funciona la facturación de este equipo", + "usageNote": "Nota:", + "usageNoteBody": "Los usuarios solo pueden pertenecer a una organización a la vez. Deben abandonar su organización actual antes de unirse a otra.", + "teamId": "ID del equipo:", + "created": "Creado:", + "yourRole": "Tu rol:", + "upgradeToCreateTeam": "Actualizar para crear un equipo", + "upgradeToCreateTeamDescription": "Actualiza a un plan de organización para crear un espacio de trabajo en equipo.", + "openSubscriptionSettings": "Abrir configuración de suscripción", + "createYourTeamWorkspace": "Crear tu espacio de trabajo en equipo", + "createYourTeamWorkspaceDescription": "Crea una organización para colaborar con tu equipo.", + "teamName": "Nombre del equipo", + "teamNamePlaceholder": "Mi equipo", + "teamUrl": "URL del equipo", + "teamSlugPlaceholder": "mi-equipo", + "createTeamWorkspace": "Crear espacio de trabajo en equipo", + "noTeamSubscriptionFound": "No se encontró ninguna suscripción de equipo", + "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}} 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.", + "currentSeats": "Asientos actuales:", + "newSeats": "Asientos nuevos:", + "monthlyCostChange": "Cambio de costo mensual:", + "loading": "Cargando...", + "reactivateSubscription": "Para actualizar los asientos, ve a Suscripción > Administrar > Mantener suscripción para reactivar", + "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?", + "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.", + "cancel": "Cancelar", + "remove": "Eliminar", + "inviteUnavailableMessage": "Se requiere una suscripción activa de organización antes de poder invitar miembros al equipo.", + "yourself": "tú mismo", + "thisMember": "este miembro", + "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.", + "confirm": "Actualizar asientos" + }, + "billing": { + "title": "Facturación del espacio de trabajo", + "description": "Asigna espacios de trabajo a la organización o al propietario.", + "organizationBillingRequired": "Se requiere facturación de organización para administrar la facturación del espacio de trabajo.", + "organizationBilledTitle": "Facturado a la organización", + "organizationBilledEmpty": "No hay espacios de trabajo facturados a la organización.", + "availableOwnerBilledTitle": "Espacios de trabajo facturados al propietario", + "availableOwnerBilledEmpty": "No hay espacios de trabajo con facturación por propietario disponibles.", + "returnToOwner": "Volver a facturación por propietario", + "billToOrganization": "Facturar a la organización", + "organization": "Organización", + "ownerBilling": "Facturación por propietario", + "ownerLabel": "Propietario:" + }, + "members": { + "title": "Miembros del equipo", + "empty": "Aún no hay miembros en el equipo.", + "sharedUsage": "Uso compartido: ${{amount}}", + "pending": "Pendiente", + "billing": "Facturación", + "usage": "Uso", + "sharedPool": "Fondo compartido", + "unknown": "Desconocido", + "removeMemberTooltip": "Eliminar miembro", + "cancelling": "Cancelando...", + "cancelInvitationTooltip": "Cancelar invitación", + "leaveOrganization": "Salir de la organización" + }, + "invitation": { + "title": "Invitar a miembros del equipo", + "description": "Invita a personas a tu organización y elige el acceso a los espacios de trabajo.", + "emailPlaceholder": "name@example.com", + "hideWorkspaces": "Ocultar espacios de trabajo", + "addWorkspaces": "Agregar espacios de trabajo", + "invite": "Invitar", + "noSeats": "No hay plazas disponibles", + "unavailable": "Invitación no disponible", + "workspaceAccess": "Acceso al espacio de trabajo", + "optional": "Opcional", + "selected": "{{count}} workspace 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}} workspace{{plural}}.", + "invalidEmail": "Ingrese una dirección de correo electrónico válida.", + "permissions": { + "read": { + "label": "Lectura", + "description": "Puede ver el contenido del espacio de trabajo." + }, + "write": { + "label": "Escritura", + "description": "Puede editar el contenido del espacio de trabajo." + }, + "admin": { + "label": "Administrador", + "description": "Puede gestionar la configuración del espacio de trabajo." + } + } + } + }, + "sso": { + "providerStatus": "Proveedor de inicio de sesión único", + "issuerUrl": "URL del emisor", + "providerId": "ID del proveedor", + "callbackUrl": "URL de callback", + "callbackUrlHelp": "Use esta URL de callback en la configuración de su proveedor de identidad.", + "providerType": "Tipo de proveedor", + "providerTypeDescriptions": { + "oidc": "OpenID Connect (Okta, Azure AD, Auth0, etc.)", + "saml": "Security Assertion Markup Language (ADFS, Shibboleth, etc.)" + }, + "selectProviderHelp": "Seleccione un ID de proveedor preconfigurado de la lista de proveedores de confianza.", + "issuerUrlHelp": "Use la URL del emisor proporcionada por su proveedor de identidad.", + "providerIdPlaceholder": "Seleccionar un ID de proveedor", + "issuerUrlPlaceholder": "Introduzca la URL del emisor", + "domain": "Dominio", + "domainPlaceholder": "Introduzca el dominio", + "clientId": "ID de cliente", + "clientIdPlaceholder": "Introduzca el ID de cliente", + "clientSecret": "Secreto de cliente", + "clientSecretPlaceholder": "Introduzca el secreto de cliente", + "scopes": "Ámbitos", + "selectProviderId": "Seleccione un ID de proveedor", + "copyCallbackUrl": "Copiar URL de callback", + "showClientSecret": "Mostrar secreto de cliente", + "hideClientSecret": "Ocultar secreto de cliente", + "scopesPlaceholder": "openid,profile,email", + "scopesDescription": "Lista separada por comas de ámbitos OIDC a solicitar.", + "entryPoint": "URL de punto de entrada", + "entryPointPlaceholder": "Introduzca la URL de punto de entrada", + "entryPointDescription": "Proporcione la URL de punto de entrada SAML de su proveedor de identidad.", + "certificate": "Certificado del proveedor de identidad", + "certificatePlaceholder": "-----BEGIN CERTIFICATE-----", + "certificateDescription": "Pegue el certificado proporcionado por su proveedor de identidad.", + "advancedOptions": "Opciones avanzadas de SAML", + "audience": "Audiencia (ID de entidad)", + "audiencePlaceholder": "Introduzca la audiencia", + "audienceDescription": "La restricción de audiencia SAML, opcional y con valor predeterminado la URL de la aplicación.", + "callbackUrlOverride": "Anulación de URL de callback", + "callbackUrlPlaceholder": "Ingrese la URL de callback", + "callbackUrlDescription": "URL de callback SAML personalizada, opcional y generada automáticamente si está vacía.", + "requireSignedAssertions": "Requerir aserciones SAML firmadas", + "metadataXml": "XML de metadatos del proveedor de identidad", + "metadataPlaceholder": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\">\n ...\n</md:EntityDescriptor>", + "metadataDescription": "Pegue el XML de metadatos IDP completo de su proveedor de identidad para una configuración avanzada.", + "loadingProviders": "Cargando proveedores...", + "noProviders": "No se encontraron proveedores.", + "save": "Guardar cambios", + "create": "Configurar proveedor SSO", + "configureProvider": "Configurar proveedor SSO", + "saving": "Guardando...", + "configuring": "Configurando...", + "selectOrganization": "Debe ser parte de una organización para configurar el inicio de sesión único.", + "onlyAdmins": "Solo los propietarios y administradores de la organización pueden configurar los ajustes de inicio de sesión único.", + "disabledTier": "El inicio de sesión único no está habilitado para este plan de facturación.", + "providerLoadError": "Error al cargar la configuración del proveedor SSO", + "providerError": "Error al configurar el proveedor SSO", + "reloadError": "Error al recargar los proveedores SSO", + "validation": { + "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.", + "issuerUrlHttps": "La URL del emisor debe usar HTTPS.", + "issuerUrlValid": "Ingrese una URL de emisor válida, como https://your-identity-provider.com/oauth2/default", + "domainRequired": "El dominio es obligatorio.", + "domainNoProtocol": "No incluya el protocolo (https://).", + "domainValid": "Ingrese un dominio válido, como your-domain.identityprovider.com", + "clientIdRequired": "El ID de cliente es obligatorio.", + "clientSecretRequired": "El Secreto de cliente es obligatorio.", + "scopesRequired": "Los alcances son obligatorios para proveedores OIDC", + "entryPointRequired": "La URL de punto de entrada es obligatoria para proveedores SAML", + "certificateRequired": "El certificado es obligatorio.", + "providerLoadFailed": "Error al cargar proveedores SSO", + "providerLoadFailedGeneric": "Error al cargar la configuración del proveedor SSO" + } + } + }, + "widgets": { + "selector": { + "categories": { + "list": "Listas", + "editor": "Editor", + "utility": "Utilidad" + }, + "selectWidget": "Seleccionar widget", + "widgetSelectionUnavailable": "La selección de widgets no está disponible" + }, + "titles": { + "empty": "Superficie vacía", + "data_chart": "Datos de mercado", + "workflow_list": "Flujos de trabajo", + "editor_workflow": "Editor de flujos", + "workflow_chat": "Chat de flujos", + "workflow_console": "Consola de flujos", + "copilot": "Copiloto", + "list_indicator": "Indicadores", + "list_mcp": "Servidores MCP", + "editor_indicator": "Editor de indicadores", + "editor_mcp": "Editor MCP", + "list_custom_tool": "Herramientas personalizadas", + "editor_custom_tool": "Editor de herramienta personalizada", + "list_skill": "Habilidades", + "editor_skill": "Editor de habilidad", + "workflow_variables": "Variables del flujo", + "watchlist": "Lista de seguimiento" + }, + "empty": { + "noWidgetSelected": "No se ha seleccionado ningún widget", + "emptyWidget": "Widget vacío", + "noWidgetDescription": "Elige un widget de la galería para empezar a usar este panel.", + "emptyWidgetDescription": "Este widget está vacío por ahora; elige otro para continuar.", + "chooseWidget": "Elegir widget", + "surfaceTitle": "Superficie vacía", + "surfaceDescription": "Estado de marcador de posición cuando el panel no tiene un widget asignado." + }, + "workflowDropdown": { + "selectWorkspaceFirst": "Selecciona primero un espacio de trabajo.", + "unableToLoad": "No se pudieron cargar los flujos de trabajo", + "workflowSelectionUnavailable": "La selección de flujos no está disponible", + "selectWorkflow": "Seleccionar flujo", + "searchPlaceholder": "Buscar flujos...", + "failedToLoad": "No se pudieron cargar los flujos de trabajo", + "retry": "Reintentar", + "loading": "Cargando flujos de trabajo...", + "noWorkflowsAvailable": "Aún no hay flujos de trabajo disponibles.", + "noWorkflowsFound": "No se encontraron flujos de trabajo.", + "untitledWorkflow": "Flujo sin título" + }, + "mcpDropdown": { + "selectWorkspaceFirst": "Selecciona primero un espacio de trabajo.", + "unableToLoad": "No se pudieron cargar los servidores MCP", + "mcpSelectionUnavailable": "La selección de MCP no está disponible", + "selectMcpServer": "Seleccionar servidor MCP", + "searchPlaceholder": "Buscar servidores...", + "failedToLoad": "No se pudieron cargar los servidores MCP", + "retry": "Reintentar", + "loading": "Cargando servidores MCP...", + "noServersAvailable": "Aún no hay servidores MCP disponibles.", + "noServersFound": "No se encontraron servidores.", + "unnamedServer": "Servidor sin nombre" + }, + "console": { + "selectWorkspace": "Selecciona un espacio de trabajo para cargar los flujos.", + "noWorkflows": "No hay flujos de trabajo disponibles en este espacio.", + "filters": "Filtros", + "status": "Estado", + "error": "Error", + "info": "Información", + "blocks": "Bloques", + "sortByTime": "Ordenar por tiempo", + "structuredView": "Vista estructurada", + "toggleStructuredView": "Alternar vista estructurada", + "wrapText": "Ajustar texto", + "toggleWrapText": "Alternar ajuste de texto", + "downloadConsoleCsv": "Descargar CSV de la consola", + "downloadCsv": "Descargar CSV", + "clearConsole": "Limpiar consola", + "noResults": "No se encontraron resultados para \"{{query}}\"" + }, + "mcpEditor": { + "selectWorkspaceToEdit": "Selecciona un espacio de trabajo para editar servidores MCP.", + "selectServerToEdit": "Selecciona un servidor MCP para editarlo.", + "saveDraftHint": "Guarda este borrador para habilitar pruebas de conexión, actualización de herramientas y recarga canónica.", + "failedToLoadMcpServers": "No se pudieron cargar los servidores MCP.", + "tools": "Herramientas", + "saveRequired": "Se requiere guardar", + "noToolsDiscovered": "Aún no se han descubierto herramientas.", + "saveThisServerToRefreshAndInspectDiscoveredMcpTools": "Guarda este servidor para actualizar e inspeccionar las herramientas MCP descubiertas.", + "refreshTools": "Actualizar herramientas", + "testConnection": "Probar conexión", + "resetForm": "Restablecer formulario", + "saveServer": "Guardar servidor", + "clearSelection": "Borrar selección", + "selectServer": "Seleccionar servidor", + "serverNameRequired": "Se requiere el nombre del servidor.", + "failedToRefreshMcpServer": "No se pudo actualizar el servidor MCP.", + "failedToSaveMcpServer": "No se pudo guardar el servidor MCP.", + "toolCount": "{{count}} en total", + "loading": "Cargando...", + "connected": "Conectado", + "error": "Error", + "draft": "Borrador", + "disconnected": "Desconectado", + "unnamedServer": "Servidor sin nombre", + "updated": "Actualizado hace {{time}}", + "toolsRefreshed": "Herramientas actualizadas hace {{time}}", + "lastConnected": "Última conexión hace {{time}}", + "lastError": "Último error" + }, + "triggerList": { + "coreTriggers": "Disparadores principales", + "integrationTriggers": "Disparadores de integración", + "searchPlaceholder": "Buscar disparadores", + "openTriggerList": "Haz clic para agregar disparador", + "close": "Cerrar", + "noResults": "No se encontraron resultados para \"{{query}}\"" + }, + "workflowToolbar": { + "selectWorkspace": "Selecciona un espacio de trabajo para explorar bloques", + "blocks": "Bloques", + "tools": "Herramientas", + "triggers": "Disparadores", + "special": "Especial", + "browseLabel": "Explorar {{label}}", + "searchPlaceholder": "Buscar {{label}}...", + "noResults": "No se encontraron {{label}}." + }, + "workflowLabels": { + "systemPrompt": "Prompt del sistema", + "userPrompt": "Prompt del usuario", + "model": "Modelo", + "temperature": "Temperatura", + "apiKey": "Clave API", + "skills": "Habilidades", + "tools": "Herramientas", + "responseFormat": "Formato de respuesta", + "reasoningEffort": "Esfuerzo de razonamiento", + "verbosity": "Nivel de detalle", + "configured": "Configurado", + "value": "Valor", + "items": "Elementos", + "fields": "Campos", + "object": "Objeto", + "block": "Bloque", + "type": "Tipo", + "none": "Ninguno", + "noValuesToDisplay": "No hay valores para mostrar.", + "error": "Error", + "if": "si", + "else": "sino", + "elseIf": "sino si", + "addSkill": "Agregar habilidad", + "searchSkills": "Buscar habilidades...", + "chooseModel": "Elegir modelo", + "lite": "Ligero", + "anthropic": "Anthropic", + "openai": "OpenAI", + "nextStep": "Siguiente paso", + "locked": "Bloqueado", + "deployed": "Desplegado", + "deployedWithVersion": "Desplegado (v{{version}})", + "notDeployed": "No desplegado", + "disabled": "Deshabilitado", + "removeSkill": "Eliminar {{name}}", + "currentWorkflow": "Flujo actual", + "currentSkill": "Habilidad actual", + "currentTool": "Herramienta actual", + "currentIndicator": "Indicador actual", + "currentMcpServer": "Servidor MCP actual", + "workflows": "Flujos de trabajo", + "customTools": "Herramientas personalizadas", + "indicators": "Indicadores", + "mcpServers": "Servidores MCP", + "allWorkflows": "Todos los flujos de trabajo" + }, + "workflowEditor": { + "previewInspector": "Inspector de vista previa", + "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}}`.", + "saveName": "Guardar nombre", + "renameNode": "Renombrar nodo", + "loopTypeLabel": "Tipo de bucle", + "parallelTypeLabel": "Tipo en paralelo", + "selectType": "Selecciona un tipo", + "forLoop": "Bucle for", + "forEachLoop": "Bucle for each", + "whileLoop": "Bucle while", + "doWhileLoop": "Bucle do while", + "parallelCount": "Conteo paralelo", + "parallelEach": "Cada elemento en paralelo", + "loopIterations": "Iteraciones del bucle", + "parallelExecutions": "Ejecuciones en paralelo", + "whileCondition": "Condición while", + "collectionItems": "Elementos de la colección", + "parallelItems": "Elementos en paralelo", + "enterValueBetween": "Introduce un valor entre 1 y {{max}}", + "hideAdditionalFields": "Ocultar campos adicionales", + "showAdditionalFields": "Mostrar campos adicionales", + "additionalFields": "Campos adicionales", + "triggerNoEditableFields": "Este disparador no tiene campos editables en el panel.", + "blockNoEditableFields": "Este bloque no tiene campos editables.", + "requiredField": "Este campo es obligatorio", + "invalidJson": "JSON no válido", + "unknownInputType": "Tipo de entrada desconocido: {{type}}", + "loop": "Bucle", + "parallel": "Paralelo", + "start": "Inicio", + "end": "Fin" + }, + "apiKey": { + "apiKey": "Clave API", + "apiKeyType": "Tipo de clave API", + "apiKeyName": "Nombre de la clave API", + "personal": "Personal", + "workspace": "Espacio de trabajo", + "selectApiKey": "Seleccionar clave API", + "myApiKey": "Mi clave API", + "ownerIsBilledForUsage": "El propietario paga el uso", + "keyOwnerIsBilled": "El propietario de la clave paga", + "createNew": "Crear nueva", + "loadingApiKeys": "Cargando claves API...", + "selectAnApiKey": "Selecciona una clave API", + "noApiKeysAvailable": "No hay claves API disponibles", + "createNewApiKey": "Crear nueva clave API", + "workspaceAccess": "Esta clave tendrá acceso a todos los flujos de trabajo de este espacio. Copia la clave después de crearla, ya que no podrás volver a verla.", + "personalAccess": "Esta clave tendrá acceso a tus flujos de trabajo personales. Copia la clave después de crearla, ya que no podrás volver a verla.", + "apiKeyHasBeenCreated": "Tu clave API ha sido creada", + "onlyTimeYouWillSeeYourApiKey": "Esta es la única vez que verás tu clave API.", + "copyItNowAndStoreItSecurely": "Cópiala ahora y guárdala de forma segura.", + "copyToClipboard": "Copiar al portapapeles", + "enterName": "Introduce un nombre para la clave API", + "failedToCreate": "No se pudo crear la clave API", + "create": "Crear", + "creating": "Creando...", + "cancel": "Cancelar", + "workspaceLabel": "Espacio de trabajo", + "personalLabel": "Personal" + }, + "entityEditor": { + "undo": "Deshacer", + "redo": "Rehacer" + }, + "pairColor": { + "selectionUnavailable": "La selección de color no está disponible", + "selectWidgetColor": "Seleccionar color del widget", + "unlinked": "Sin vincular", + "red": "Rojo", + "orange": "Naranja", + "blue": "Azul", + "green": "Verde", + "purple": "Morado" + }, + "deployment": { + "adminPermissionsRequiredToDeployWorkflows": "Se requieren permisos de administrador para desplegar flujos", + "deploying": "Desplegando...", + "workflowChangesDetected": "Se detectaron cambios en el flujo", + "deploymentSettings": "Configuración de despliegue", + "deployWorkflow": "Desplegar flujo", + "deployApi": "Desplegar API", + "needsRedeployment": "Necesita redepliegue", + "deployWorkflowTitle": "Desplegar flujo", + "active": "activo", + "close": "Cerrar", + "deploymentError": "Error de despliegue", + "expandSidebar": "Expandir barra lateral", + "collapseSidebar": "Contraer barra lateral", + "selectSharedDeploymentApiKeyInBillingBeforeDeployingThisApiTrigger": "Selecciona una clave API compartida de despliegue en Facturación antes de desplegar este disparador de API.", + "thisApiTriggerUsesTheSharedDeploymentApiKey": "Este disparador de API usa la clave API compartida de despliegue {displayKey}.", + "thisApiTriggerUsesTheSharedDeploymentApiKeySelectedInBilling": "Este disparador de API usa la clave API compartida de despliegue seleccionada en Facturación.", + "thisApiTriggerWillUseTheSharedDeploymentApiKeyCurrentlySelectedInBilling": "Este disparador de API usará la clave API compartida de despliegue seleccionada actualmente en Facturación." + }, + "workflowCreateMenu": { + "createButtonTooltip": "Crear carpeta o flujo de trabajo", + "selectWorkspaceTooltip": "Selecciona un espacio de trabajo para crear flujos", + "createWorkflow": "Nuevo flujo", + "createFolder": "Nueva carpeta", + "importWorkflow": "Importar flujo", + "creating": "Creando...", + "importing": "Importando..." + } + }, + "layoutTabs": { + "createNewLayout": "Crear nuevo diseño" + } + } +} diff --git a/apps/tradinggoose/i18n/messages/zh-CN.json b/apps/tradinggoose/i18n/messages/zh-CN.json new file mode 100644 index 000000000..17fb0046a --- /dev/null +++ b/apps/tradinggoose/i18n/messages/zh-CN.json @@ -0,0 +1,1800 @@ +{ + "meta": { + "landing": { + "title": "TradingGoose - 面向 LLM 交易的可视化工作流平台 | 开源", + "description": "面向技术型 LLM 驱动交易的开源平台。使用 PineTS 编写自定义指标,监控实时行情,并在市场信号触发时运行 AI 代理工作流。", + "openGraphTitle": "TradingGoose - 面向 LLM 交易的可视化工作流平台", + "openGraphDescription": "面向技术型 LLM 驱动交易的开源平台。使用 PineTS 编写自定义指标,监控实时行情,并在市场信号触发时运行 AI 代理工作流。", + "seo": { + "keywords": "AI 交易工作流,LLM 交易代理,技术型交易自动化,自定义交易指标,PineTS 指标,可视化交易工作流构建器,交易信号自动化,市场数据工作流,回测平台,开源交易平台,算法交易,AI 交易助手", + "socialPreviewAlt": "TradingGoose 社交预览", + "llmContentType": "面向交易的可视化工作流平台,自定义指标,面向市场的 AI 代理工作流", + "llmUseCases": "基于信号的交易执行,投资组合再平衡,指标提醒,策略回测,市场情绪分析,自定义交易仪表板", + "llmIntegrations": "OpenAI、Anthropic、Google Gemini、xAI、Mistral、Perplexity、Ollama,以及自定义市场数据提供商", + "llmPricing": "请查看 tradinggoose.ai 上的托管定价" + } + }, + "blog": { + "title": "博客 | TradingGoose", + "description": "关于交易自动化、工作流设计以及构建更智能策略的文章。" + }, + "privacy": { + "title": "隐私政策 | TradingGoose", + "description": "TradingGoose Studio 的隐私政策,涵盖账户数据、工作流、已连接服务、分析、账单和保留实践。" + } + }, + "nav": { + "docs": "文档", + "blog": "博客", + "login": "登录", + "menu": "菜单", + "homeLabel": "首页", + "languageLabel": "语言" + }, + "registration": { + "open": { + "primary": "开始使用", + "auth": "注册" + }, + "waitlist": { + "primary": "加入等待名单", + "auth": "加入等待名单" + }, + "disabled": { + "primary": "即将上线", + "auth": null + } + }, + "auth": { + "common": { + "email": "邮箱", + "password": "密码", + "fullName": "全名", + "workEmail": "工作邮箱", + "enterYourEmail": "请输入邮箱", + "enterYourPassword": "请输入密码", + "enterYourName": "请输入姓名", + "enterYourWorkEmail": "请输入工作邮箱", + "forgotPassword": "忘记密码?", + "showPassword": "显示密码", + "hidePassword": "隐藏密码", + "continueWith": "或继续使用", + "or": "或", + "signIn": "登录", + "signUp": "注册", + "alreadyHaveAccount": "已经有账号?", + "dontHaveAccount": "还没有账号?", + "termsLeadSigningIn": "登录即表示你同意我们的", + "termsLeadCreatingAccount": "创建账号即表示你同意我们的", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "and": "和", + "returnHome": "返回首页", + "backToLogin": "返回登录", + "backToSignup": "返回注册", + "verifyEmail": "验证邮箱", + "signInWithEmail": "使用邮箱登录", + "signInWithSso": "使用 SSO 登录", + "continueWithSso": "继续使用 SSO", + "requestAccess": "申请访问" + }, + "error": { + "eyebrow": "身份验证错误", + "codeLabel": "错误代码", + "supportPrefix": "如果问题仍然存在,请联系", + "supportLinkLabel": "支持", + "supportSuffix": "并附上错误代码。", + "default": { + "title": "出了点问题", + "description": "我们无法完成该身份验证请求。请重新登录后再试。" + }, + "groups": { + "accountCreation": { + "title": "无法创建你的账号", + "description": "你的注册请求未能完成。请从注册表单重试,或者如果这个邮箱已注册,请直接登录。" + }, + "accountExists": { + "title": "账号已存在", + "description": "该邮箱对应的账号已经注册。请直接登录,不要再创建新账号。" + }, + "emailVerification": { + "title": "请先验证邮箱", + "description": "你的账号已存在,但还需要完成邮箱验证。" + }, + "invalidCallback": { + "title": "这个登录链接无效", + "description": "身份验证回调无效。请重新开始登录流程。" + }, + "invalidToken": { + "title": "这个身份验证链接无效", + "description": "无法验证该链接或令牌。请重新开始身份验证流程。" + }, + "expiredToken": { + "title": "这个身份验证链接已过期", + "description": "链接或令牌已过期。请重新开始身份验证流程。" + }, + "sessionCreation": { + "title": "无法启动你的会话", + "description": "身份验证已成功,但无法创建会话。请重新登录。" + }, + "sessionRestore": { + "title": "无法恢复你的会话", + "description": "无法加载你的会话。请重新登录。" + }, + "sessionExpired": { + "title": "你的会话已过期", + "description": "请重新登录以继续。" + }, + "userInfo": { + "title": "无法完成登录", + "description": "无法从提供方读取你的身份。请重新登录再试。" + }, + "providerUnavailable": { + "title": "该登录提供方不可用", + "description": "请求的登录提供方当前未配置。" + }, + "linkedAccount": { + "title": "该提供方已关联", + "description": "该登录提供方已经连接到另一个账号。请使用其他方式继续。" + }, + "waitlistLimited": { + "title": "注册受限", + "description": "注册仅限已获批的等待名单邮箱。" + }, + "registrationDisabled": { + "title": "注册已暂时禁用", + "description": "当前已禁用注册。" + } + } + }, + "disabled": { + "title": "注册已关闭", + "description": "当前已禁用注册。" + }, + "note": { + "waitlistApprovedEmail": "任何登录方式都请使用同一个已获批的等待名单邮箱。" + }, + "social": { + "github": "GitHub", + "google": "Google", + "connecting": "连接中..." + }, + "verify": { + "eyebrow": "验证", + "pendingTitle": "验证你的邮箱", + "verifiedTitle": "邮箱已验证!", + "verifiedDescription": "你的邮箱已验证。正在跳转到仪表板...", + "disabledDescription": "邮箱验证已禁用。正在跳转到仪表板...", + "codeSent": "验证码已发送到{{email}}", + "developmentDescription": "开发模式:请查看控制台日志中的验证码", + "missingServiceDescription": "错误:已启用邮箱验证,但未配置邮箱服务", + "instructionsWithService": "请输入 6 位验证码以验证你的账号。如果收件箱里没有,请检查垃圾邮件文件夹。", + "instructionsWithoutService": "请输入 6 位验证码以验证你的账号。", + "verifyButton": "验证邮箱", + "verifyingButton": "验证中...", + "resendPrompt": "没有收到验证码?", + "resendIn": "{{countdown}}秒后重新发送", + "resendButton": "重新发送", + "yourEmail": "你的邮箱", + "errors": { + "invalid": "验证码无效,请检查后重试。", + "expired": "验证码已过期,请申请新的验证码。", + "generic": "验证失败,请检查验证码后重试。", + "attempts": "失败次数过多,请申请新的验证码。", + "resendFailed": "重新发送验证码失败,请稍后再试。" + } + }, + "login": { + "eyebrow": "登录", + "title": "欢迎回来", + "description": "请输入你的凭据", + "submit": "登录", + "submitting": "登录中...", + "divider": "或继续使用", + "resetDialog": { + "title": "重置密码", + "description": "输入你的邮箱,如果账号存在,我们会发送重置密码链接。", + "emailLabel": "邮箱", + "emailPlaceholder": "请输入邮箱", + "emailRequired": "请输入你的邮箱。", + "emailInvalid": "请输入有效的邮箱地址。", + "success": "密码重置链接已发送到你的邮箱", + "error": "无法请求重置密码", + "submit": "发送重置链接", + "submitting": "发送中..." + }, + "validation": { + "emailRequired": "邮箱为必填项。", + "emailInvalid": "请输入有效的邮箱地址。", + "passwordRequired": "密码为必填项。", + "passwordEmpty": "密码不能为空。" + }, + "errors": { + "sessionExpired": "你的会话已过期。请重新登录。", + "emailSignInDisabled": "邮箱登录当前已禁用。", + "invalidCredentials": "邮箱或密码无效。请重试。", + "noAccount": "未找到该邮箱对应的账号。请先注册。", + "missingCredentials": "请输入邮箱和密码。", + "emailPasswordDisabled": "邮箱和密码登录已禁用。", + "failedToCreateSession": "无法创建会话。请稍后再试。", + "tooManyAttempts": "登录尝试过多。请稍后再试或重置密码。", + "accountLocked": "你的账号因安全原因已被锁定。请重置密码。", + "network": "网络错误。请检查连接后重试。", + "rateLimit": "请求过多。请稍等片刻后再试。", + "unableToSignIn": "无法登录。", + "unableToSignInNow": "目前无法登录。请重试。" + } + }, + "signup": { + "eyebrow": "注册", + "title": "创建账号", + "descriptionOpen": "创建账号或登录", + "descriptionWaitlist": "需要已获批的等待名单访问权限", + "submit": "创建账号", + "submitting": "创建中...", + "divider": "或继续使用", + "nameTitle": "姓名只能包含字母、空格、连字符和撇号", + "validation": { + "emailRequired": "邮箱为必填项。", + "emailInvalid": "请输入有效的邮箱地址。", + "nameRequired": "姓名为必填项。", + "nameEmpty": "姓名不能为空。", + "nameCharacters": "姓名只能包含字母、空格、连字符和撇号。", + "nameSpaces": "姓名不能包含连续空格。", + "nameTooLong": "姓名将被截断为 100 个字符。请缩短姓名。", + "passwordMinLength": "密码至少需要 8 个字符。", + "passwordUppercase": "密码必须包含至少一个大写字母。", + "passwordLowercase": "密码必须包含至少一个小写字母。", + "passwordNumber": "密码必须包含至少一个数字。", + "passwordSpecial": "密码必须包含至少一个特殊字符。" + }, + "errors": { + "failedToCreateAccount": "无法创建账号", + "accountExists": "该邮箱已存在账号。请直接登录。", + "emailSignupDisabled": "邮箱注册当前已禁用。", + "signupNotEnabled": "邮箱和密码注册未启用。", + "waitlistRequired": "该邮箱尚未获批注册。请先加入等待名单。", + "invalidEmail": "请输入有效的邮箱地址。", + "passwordTooShort": "密码至少需要 8 个字符。", + "passwordTooLong": "密码长度必须少于 128 个字符。", + "network": "网络错误。请检查连接后重试。", + "rateLimit": "请求过多。请稍等片刻后再试。" + } + }, + "waitlist": { + "eyebrow": "等待名单", + "title": "申请访问 TradingGoose", + "description": "加入平台访问队列。如果你的邮箱已经获批,可以直接从这里继续注册。", + "helperText": "使用你希望审核的平台访问邮箱。", + "submit": "申请访问", + "submitting": "提交中...", + "pending": "你已在等待名单中。我们会审核你的申请,并在可访问时通知你。", + "approvedPrefix": "你的邮箱已获批。继续前往", + "signedUpPrefix": "该邮箱已拥有访问权限。继续前往", + "rejected": "该等待名单申请未获批访问权限。", + "signUpLink": "注册", + "loginLink": "登录", + "validation": { + "emailRequired": "邮箱为必填项。", + "emailInvalid": "请输入有效的邮箱地址。" + } + }, + "sso": { + "eyebrow": "SSO", + "title": "使用 SSO 登录", + "description": "输入你的工作邮箱继续", + "submit": "继续使用 SSO", + "submitting": "正在跳转到 SSO 提供方...", + "divider": "或", + "emailButton": "使用邮箱登录", + "validation": { + "emailRequired": "邮箱为必填项。", + "emailInvalid": "请输入有效的邮箱地址。" + }, + "errors": { + "accountNotFound": "未找到账号。请联系管理员配置 SSO 访问。", + "ssoFailed": "SSO 身份验证失败。请重试。", + "providerNotConfigured": "SSO 提供方配置不正确。", + "invalidEmailDomain": "邮箱域名未配置为 SSO。请联系管理员。", + "network": "网络错误。请检查连接后重试。", + "rateLimit": "请求过多。请稍等片刻后再试。", + "ssoDisabled": "SSO 身份验证已禁用。请使用其他登录方式。", + "failed": "无法使用 SSO 登录。请重试。" + } + } + }, + "localeNames": { + "en": "English", + "es": "Español", + "zh-CN": "简体中文" + }, + "landing": { + "hero": { + "statusBadges": { + "disabled": "嘟嘟!TradingGoose-Studio 即将上线", + "waitlist": "嘟嘟!TradingGoose-Studio 介绍中", + "open": "嘟嘟!TradingGoose-Studio 已上线" + }, + "leadWords": [ + "构建", + "测试", + "运行" + ], + "highlightWords": [ + "交易分析", + "信号检测", + "风险评估" + ], + "titleConnector": "你的", + "suffix": ",尽在 TradingGoose", + "description": "连接你自己的数据提供商,编写自定义指标监控市场价格,并将它们接入可触发交易、卖出、买入或任何自定义动作的工作流。", + "featureBadges": [ + "AI 代理工作流", + "自定义指标", + "接入你的数据", + "集成" + ], + "learnMore": "了解更多" + }, + "cta": { + "title": "让 AI 代理为你的交易策略工作。", + "description": "看看社区正在用 TradingGoose 构建什么。", + "joinDiscord": "加入 Discord", + "placeholder": "you@example.com", + "subscribe": "获取更新", + "subscribing": "订阅中...", + "success": "已订阅!请查看收件箱。", + "error": "出错了。" + }, + "footer": { + "description": "面向技术型 LLM 交易的 AI 工作流平台", + "copyright": "© {{year}} {{brand}}。为可视化交易工作流而生。", + "links": { + "docs": "文档", + "blog": "博客", + "widgets": "组件", + "indicators": "指标", + "blocks": "模块", + "tools": "工具", + "changelog": "更新日志", + "privacy": "隐私政策", + "licenses": "许可证", + "terms": "服务条款" + }, + "social": { + "discord": "Discord", + "github": "GitHub" + }, + "hoverText": "HONK!" + }, + "preview": { + "shell": { + "headerAriaLabel": "窗口小部件标题", + "widgetLabel": "小部件" + }, + "layout": { + "headerAriaLabel": "窗口小部件标题", + "sizeLabel": "小部件尺寸" + }, + "indicatorDropdown": { + "placeholder": "选择指标", + "tooltip": "选择指标", + "searchPlaceholder": "搜索指标...", + "emptyWithQuery": "未找到指标。", + "emptyWithoutQuery": "暂无可用指标。" + }, + "market": { + "indicatorUnavailableError": "此展示中该指标不可用。" + }, + "workflow": { + "zoomOut": "缩小工作流预览", + "zoomIn": "放大工作流预览", + "selectorAriaLabel": "工作流预览选择器", + "demos": { + "signalBriefing": "信号简报", + "investmentDebate": "投资辩论", + "riskRouting": "风险路由" + } + } + }, + "howItWorks": { + "eyebrow": "工作原理", + "title": "从数据到决策", + "description": "连接您自己的数据源,使用自定义指标监控市场,让AI智能体分析关键信息,并触发代表您执行的工作流。", + "processes": [ + { + "title": "连接数据", + "description": "接入任意市场数据提供商,将实时价格流式传输到工作区。" + }, + { + "title": "用指标监控", + "description": "编写自定义PineTS指标,监测您关心的条件。" + }, + { + "title": "用AI智能体分析", + "description": "让LLM驱动的智能体模块评估信号、衡量风险并自主做出决策。" + }, + { + "title": "触发工作流", + "description": "当信号触发时,启动工作流以进行交易、告警、记录或您定义的任何其他操作。" + } + ] + }, + "monitorSection": { + "eyebrow": "实时监控", + "title": "触发工作流的指标", + "description": "设置监视器,监控您在实时市场数据上的指标。当信号触发时,工作流自动运行——下达订单、发送告警、记录结果或任何其他操作。", + "bullets": [ + "使用您自己的凭据连接任何流数据提供商", + "为每个标的选择要监控的指标和时间间隔", + "将触发路由到任何已部署的工作流" + ], + "tableHeaders": { + "listing": "标的", + "indicator": "指标", + "workflow": "工作流", + "status": "状态" + }, + "statuses": { + "pending": "待处理", + "running": "运行中", + "success": "成功", + "failed": "失败" + }, + "indicatorOptions": [ + { + "name": "RSI < 30", + "color": "#8b5cf6" + }, + { + "name": "MACD Cross", + "color": "#14b8a6" + }, + { + "name": "EMA 21/50", + "color": "#f59e0b" + }, + { + "name": "Supertrend", + "color": "#ef4444" + }, + { + "name": "BB Squeeze", + "color": "#3b82f6" + }, + { + "name": "成交量突增", + "color": "#10b981" + } + ], + "workflowOptions": [ + { + "name": "情绪分析", + "color": "#6366f1" + }, + { + "name": "风险评估", + "color": "#f59e0b" + }, + { + "name": "投资组合再平衡", + "color": "#22c55e" + }, + { + "name": "财报检查", + "color": "#3b82f6" + }, + { + "name": "社交媒体扫描", + "color": "#8b5cf6" + }, + { + "name": "波动率分析", + "color": "#ef4444" + }, + { + "name": "板块相关性", + "color": "#14b8a6" + } + ] + }, + "features": { + "eyebrow": "功能特性", + "title": "你的工作空间,随心配置", + "description": "布局、图表和工作流——每个组件既可独立运行,也可协同配合。", + "rows": [ + { + "badge": "工作空间", + "title": "组件布局", + "description": "拆分工作空间,将组件并排或叠放。保存布局并在不同工作空间之间切换命名布局。", + "bullets": [ + "递归拆分", + "按工作空间保存布局", + "共享组件操作菜单" + ] + }, + { + "badge": "图表", + "title": "指标与实时数据", + "description": "内置指标及用于编写自定义指标的 PineTS 编辑器。连接您自己的数据提供商,实时监控价格。", + "bullets": [ + "可配置的指标输入", + "每个 K 线自动重新执行", + "十字准线图例与图表标记" + ] + }, + { + "badge": "工作流", + "title": "AI 驱动的工作流", + "description": "在画布上搭建工作流,使用能基于大语言模型做出决策的 AI 智能体模块。集成 Slack、Discord、GitHub、Gmail 等服务,并将订单路由至 Alpaca 或 Tradier。", + "bullets": [ + "用于自主分析与决策的 AI 智能体模块", + "集成 Slack、Discord、GitHub、Gmail 等", + "数据、条件、循环、并行和交易操作模块" + ] + } + ] + }, + "integrations": { + "eyebrow": "集成", + "title": "不止于提示词的大语言模型。", + "bullets": [ + "每个集成都成为一个可由 AI 智能体调用的工具", + "内置消息、数据库、云存储、CRM 及搜索模块", + "您自行定义的自定义 MCP 服务器、技能和工具" + ], + "structuredData": { + "name": "TradingGoose 集成", + "description": "TradingGoose 作为可调用的工作流模块所集成的第三方服务、大语言模型提供商、数据源和工具。" + } + } + }, + "blog": { + "pageTitle": "博客", + "pageDescription": "关于交易自动化、工作流设计以及构建更智能策略的见解。已有 {{count}} 篇文章,持续更新中。", + "searchPlaceholder": "搜索文章", + "emptyTitle": "暂无文章", + "emptyDescription": "请稍后回来查看,新的文章正在路上。", + "noMatches": "没有匹配“{{query}}”的文章", + "noMatchesDescription": "试试其他搜索词。", + "readTimeSuffix": "分钟阅读", + "viewArticle": "查看文章", + "home": "首页", + "breadcrumbBlog": "博客", + "articleSingular": "文章", + "articlePlural": "文章" + }, + "privacy": { + "title": "隐私政策", + "description": "TradingGoose Studio 的隐私政策,涵盖账户数据、工作流、已连接服务、分析、账单和保留实践。", + "lastUpdatedLabel": "更新日期:", + "lastUpdated": "2026年3月28日" + }, + "workspace": { + "entry": { + "loading": "正在加载工作空间..." + }, + "switcher": { + "failedToCreateWorkspace": "无法创建工作空间", + "failedToRenameWorkspace": "无法重命名工作空间", + "failedToDeleteWorkspace": "无法删除工作空间", + "roles": { + "owner": "所有者", + "admin": "管理员", + "member": "成员", + "read": "只读" + }, + "workspaceLabel": "工作空间", + "noWorkspacesYet": "还没有工作空间。", + "noWorkspacesAvailable": "没有可用的工作空间。", + "manage": "管理", + "create": "创建", + "creating": "正在创建..." + }, + "nav": { + "groups": { + "workspace": "工作区", + "system": "系统", + "more": "更多" + }, + "workspace": { + "dashboard": "仪表盘", + "knowledge": "知识", + "files": "文件", + "logs": "日志" + }, + "more": { + "environment": "环境变量", + "apiKeys": "API 密钥", + "integrations": "集成" + }, + "admin": { + "overview": "概览", + "billing": "账单", + "services": "服务", + "integrations": "集成", + "registration": "注册" + }, + "systemAdmin": "系统管理员" + }, + "defaults": { + "newWorkspaceName": "我的工作空间", + "defaultLayoutName": "默认布局", + "defaultWorkflowDescription": "你的第一个工作流 - 从这里开始构建!" + }, + "naming": { + "workspacePrefix": "工作空间", + "folderPrefix": "文件夹", + "subfolderPrefix": "子文件夹" + }, + "dashboard": { + "title": "仪表盘", + "searchPlaceholder": "搜索工作空间内容...", + "sections": { + "workspaces": "工作空间", + "knowledgeBases": "知识库", + "pages": "页面", + "docs": "文档" + }, + "emptySearch": "没有匹配内容", + "pages": { + "logs": "日志", + "knowledge": "知识", + "templates": "模板", + "docs": "文档" + } + }, + "knowledge": { + "title": "知识库", + "searchPlaceholder": "搜索知识库...", + "sort": { + "lastUpdated": "最近更新", + "newestFirst": "最新优先", + "oldestFirst": "最早优先", + "nameAsc": "名称(A-Z)", + "nameDesc": "名称(Z-A)", + "mostDocuments": "文档最多", + "leastDocuments": "文档最少" + }, + "actions": { + "create": "创建", + "createTooltip": "创建知识库需要写入权限" + }, + "errors": { + "load": "加载知识库时出错:{{error}}", + "retry": "重试" + }, + "emptyState": { + "createFirst": "创建你的第一个知识库", + "withEditPermission": "上传文档即可为你的代理创建知识库。", + "withoutEditPermission": "知识库会显示在这里。请联系管理员创建知识库。", + "buttonCreate": "创建知识库", + "buttonContactAdmin": "联系管理员", + "noMatches": "没有符合搜索条件的知识库。" + } + }, + "logs": { + "title": { + "logs": "日志", + "monitors": "监控", + "dashboard": "仪表盘" + }, + "searchPlaceholder": "搜索日志...", + "live": "实时", + "monitorRequirement": "请配置一个使用指标作为触发器、并发出触发信号的指标的工作流,以添加监控。", + "actions": { + "addMonitor": "添加监控", + "refresh": "刷新", + "refreshing": "刷新中...", + "exportCsv": "导出 CSV" + }, + "errors": { + "fetchLogs": "获取日志失败" + }, + "dashboard": { + "title": "仪表盘", + "searchPlaceholder": "搜索工作流...", + "failedToFetchExecutionHistory": "获取执行历史失败。", + "loadingExecutionHistory": "正在加载执行历史...", + "errorLoadingData": "加载执行历史时出错。", + "noExecutionHistory": "未找到执行历史。", + "noExecutionHistoryDescription": "请尝试调整筛选条件或时间范围。", + "refresh": "刷新", + "refreshing": "正在刷新...", + "chart": { + "noData": "暂无数据。", + "toggleSeries": "切换系列 {{label}}" + }, + "filters": { + "title": "筛选条件", + "activeFilters": "当前筛选条件", + "clearAll": "清除全部", + "suggestedFilters": "建议筛选条件", + "textSearch": "文本搜索", + "searchPlaceholder": "搜索日志...", + "filterOptionsPlaceholder": "未找到 {{title}} 的选项。", + "searchWorkflows": "搜索工作流...", + "searchFolders": "搜索文件夹...", + "searchOptions": "搜索选项...", + "loadingWorkflows": "正在加载工作流...", + "loadingFolders": "正在加载文件夹...", + "noWorkflows": "未找到工作流。", + "noFolders": "未找到文件夹。", + "noOptions": "未找到选项。", + "allWorkflows": "所有工作流", + "selectedWorkflows": "已选择 {{count}} 个工作流{{plural}}", + "allFolders": "所有文件夹", + "selectedFolders": "已选择 {{count}} 个文件夹{{plural}}", + "allTriggers": "所有触发器", + "selectedTriggers": "已选择 {{count}} 个触发器{{plural}}", + "allTime": "所有时间", + "past30Minutes": "过去30分钟", + "pastHour": "过去1小时", + "past6Hours": "过去6小时", + "past12Hours": "过去12小时", + "past24Hours": "过去24小时", + "past3Days": "过去3天", + "past7Days": "过去7天", + "past14Days": "过去14天", + "past30Days": "过去30天", + "manual": "手动", + "api": "API", + "webhook": "Webhook", + "schedule": "计划", + "chat": "聊天", + "error": "错误", + "info": "信息", + "anyStatus": "任意状态", + "level": "级别", + "workflow": "工作流", + "folder": "文件夹", + "trigger": "触发器", + "timeline": "时间线", + "retentionPolicy": "日志保留策略", + "retentionDescription": "此层级上的日志将在{{days}}天后自动删除。", + "upgradePlan": "升级套餐" + }, + "metrics": { + "totalExecutions": "总执行次数", + "successRate": "成功率", + "failedExecutions": "执行失败数", + "activeWorkflows": "活跃工作流" + }, + "workflows": { + "title": "工作流", + "legend": "每个单元格大约为所选范围的{{duration}}。点击单元格可筛选详细信息。", + "count": "{{count}}个工作流", + "countPlural": "{{count}}个工作流", + "filteredFrom": "(从{{count}}个中筛选)", + "noMatches": "未找到匹配\"{{query}}\"的工作流。", + "selectedSegment": "已选时段", + "filteredTo": "筛选至{{timestamp}}", + "selectedRangeMore": "(+{{count}}个片段{{plural}})", + "selectedRangeExecutions": "— {{count}}次执行{{plural}}", + "clearFilter": "清除筛选", + "executions": "执行次数", + "success": "成功", + "failures": "失败", + "errorRate": "错误率", + "duration": "时长", + "columns": { + "time": "时间", + "status": "状态", + "trigger": "触发器", + "cost": "成本", + "workflow": "工作流", + "output": "输出", + "duration": "时长" + }, + "noExecutions": "无执行记录", + "loadingMore": "加载更多...", + "scrollToLoadMore": "滚动以加载更多", + "succeeded": "{{success}}/{{total}} 成功", + "segment": "分段 {{index}}", + "allWorkflows": "所有工作流", + "multipleSelected": "已选择 {{count}} 个工作流", + "durationDay": "{{count}} 天{{plural}}", + "durationHour": "{{count}} 小时{{plural}}", + "durationMinute": "{{count}} 分钟{{plural}}" + } + }, + "list": { + "headers": { + "time": "时间", + "status": "状态", + "workflow": "工作流", + "cost": "成本", + "trigger": "触发器", + "duration": "时长" + }, + "loading": "正在加载日志...", + "loadingMore": "正在加载更多...", + "scrollToLoadMore": "滚动以加载更多", + "noLogs": "未找到日志", + "unknownWorkflow": "未知工作流" + }, + "details": { + "title": "日志详情", + "previous": "上一条日志", + "next": "下一条日志", + "close": "关闭", + "selectLog": "选择一条日志查看详情", + "timestamp": "时间戳", + "workflow": "工作流", + "executionId": "执行ID", + "level": "级别", + "trigger": "触发器", + "duration": "时长", + "loading": "正在加载详情…", + "workflowState": "工作流状态", + "viewSnapshot": "查看快照", + "toolCalls": "工具调用", + "files": "文件", + "costBreakdown": "费用明细", + "baseExecution": "基本执行:", + "modelInput": "模型输入:", + "modelOutput": "模型输出:", + "total": "总计:", + "tokens": "令牌:", + "modelBreakdown": "模型明细 ({{count}})", + "input": "输入:", + "output": "输出:", + "totalCostNote": "总费用包含基础执行费 {{amount}} 加上模型使用费用。", + "unknownSize": "未知大小", + "unknownType": "未知类型", + "unknownWorkflow": "未知", + "unknownLevel": "未知", + "unknownValue": "未知", + "traceSpans": { + "workflowExecution": "工作流执行", + "collapseAll": "全部折叠", + "expandAll": "全部展开", + "collapse": "折叠", + "expand": "展开", + "noTraceData": "无追踪数据。", + "model": "模型", + "loadSkill": "加载技能", + "initialResponse": "初始响应", + "modelResponse": "模型响应", + "modelGeneration": "模型生成", + "tokens": "{{count}} 令牌{{plural}}", + "tokensUnavailable": "令牌不可用", + "tokensInOut": "{{input}} 输入 / {{output}} 输出", + "tokensTotal": "{{count}} 总令牌{{plural}}", + "tokensTotalSuffix": " ({{count}} 总计)", + "input": "输入", + "output": "输出", + "total": "总计", + "start": "开始", + "plusMs": "+{{ms}} 毫秒", + "betweenBlocks": "{{ms}} 毫秒间隔", + "inputSection": "输入", + "outputSection": "输出", + "errorSection": "错误", + "segmentTimingTooltip": "{{type}}{{nameSuffix}} 耗时 {{duration}} 毫秒" + }, + "download": { + "downloading": "下载中...", + "download": "下载" + } + }, + "monitors": { + "loading": "正在加载监控器...", + "noConfigured": "未配置监控器。", + "status": "状态", + "provider": "提供商", + "auth": "认证", + "listing": "列表", + "indicator": "指标", + "workflow": "工作流", + "actions": "操作", + "active": "活跃", + "paused": "已暂停", + "configured": "已配置", + "missing": "缺失", + "edit": "编辑", + "pause": "暂停", + "activate": "激活", + "remove": "移除", + "searchProviders": "搜索提供商...", + "noProviders": "未找到提供商。", + "searchOptions": "搜索选项...", + "noOptions": "未找到选项。", + "searchIntervals": "搜索间隔...", + "noIntervals": "未找到间隔。", + "searchWorkflows": "搜索工作流...", + "noWorkflows": "未找到工作流。", + "searchIndicators": "搜索指标...", + "noIndicators": "未找到指标。", + "loadRequirements": "正在加载监控器需求...", + "noDeployedWorkflow": "没有可用的已部署的含指标触发器的工作流,或没有支持触发功能的指标。", + "failedToLoad": "加载监控器失败。", + "failedToFetchLogs": "获取监控器日志失败。", + "failedToSave": "保存监控器失败。", + "activateDisabled": "激活此监控器前,需要有一个已部署的且包含支持触发功能的指标的工作流。", + "failedToUpdateState": "更新监控器状态失败。", + "failedToDelete": "删除监控器失败。" + }, + "editor": { + "provider": "提供方", + "auth": "认证", + "listing": "品种", + "interval": "周期", + "workflow": "工作流", + "indicator": "指标", + "description": "配置监控详情。", + "feed": "数据源", + "selectInterval": "选择周期", + "selectWorkflow": "选择工作流", + "selectIndicator": "选择指标", + "save": "保存更改", + "create": "创建监控", + "saving": "保存中...", + "error": "错误", + "cancel": "取消", + "createTitle": "创建监控", + "editTitle": "编辑监控", + "selectProvider": "选择提供方", + "searchProviders": "搜索提供方...", + "searchOptions": "搜索选项...", + "searchIntervals": "搜索周期...", + "searchWorkflows": "搜索工作流...", + "searchIndicators": "搜索指标...", + "noProviders": "未找到提供方。", + "noOptions": "未找到选项。", + "noIntervals": "未找到时段。", + "noWorkflows": "未找到工作流。", + "noIndicators": "未找到指标。", + "authConfigured": "已配置", + "authMissing": "缺失" + } + }, + "environment": { + "title": "环境变量", + "searchPlaceholder": "搜索变量...", + "scope": { + "workspace": "工作区", + "personal": "个人" + }, + "create": { + "workspace": "创建工作区环境变量", + "personal": "创建个人环境变量" + }, + "emptyState": { + "workspace": { + "title": "暂无工作区变量", + "description": "创建一个以开始配置。" + }, + "personal": { + "title": "暂无个人变量", + "description": "创建一个以开始配置。" + } + }, + "searchEmpty": { + "workspace": "未找到匹配“{{query}}”的工作区环境变量。", + "personal": "未找到匹配“{{query}}”的个人环境变量。" + }, + "headers": { + "createdAt": "创建时间", + "variable": "变量", + "value": "值", + "updatedAt": "更新时间", + "actions": "操作" + }, + "labels": { + "untitledVariable": "未命名的变量", + "overriddenByWorkspaceVariable": "被工作区变量覆盖", + "revealValue": "显示值", + "hideValue": "隐藏值", + "copyValue": "复制环境变量值", + "save": "保存环境变量", + "cancel": "取消编辑", + "edit": "编辑环境变量", + "delete": "删除环境变量" + } + }, + "apiKeys": { + "title": "API 密钥", + "cardTitle": "{{scope}} API 密钥", + "searchPlaceholder": "搜索密钥...", + "scope": { + "workspace": "工作区", + "personal": "个人" + }, + "create": { + "workspace": "创建工作区密钥", + "personal": "创建个人密钥" + }, + "emptyState": { + "workspace": { + "title": "暂无工作区 API 密钥", + "description": "创建一个即可立即开始集成。", + "button": "创建密钥" + }, + "personal": { + "title": "暂无个人 API 密钥", + "description": "创建一个即可立即开始集成。", + "button": "创建密钥" + } + }, + "searchEmpty": "未找到与\"{{query}}\"匹配的{{scope}} API 密钥。", + "headers": { + "createdAt": "创建时间", + "name": "名称", + "key": "密钥", + "lastUpdate": "最后更新", + "actions": "操作" + }, + "labels": { + "never": "从不", + "lastUsed": "上次使用:{{date}}", + "saveName": "保存API密钥名称", + "rename": "重命名{{scope}}API密钥", + "reveal": "显示{{scope}}API密钥", + "hide": "隐藏{{scope}}API密钥", + "copy": "复制{{scope}}API密钥", + "save": "保存{{scope}}API密钥", + "cancelRename": "取消重命名", + "delete": "删除{{scope}}API密钥", + "nameRequired": "名称为必填项", + "duplicateName": "名为\"{{name}}\"的{{scope}}API密钥已存在。", + "failedRename": "重命名{{scope}}API密钥失败。", + "unableRename": "无法重命名{{scope}}API密钥。请重试。", + "failedCreate": "创建{{scope}}API密钥失败。请重试。", + "workspaceAccess": "此密钥授予对该工作区内所有工作流和文件的访问权限。创建后立即复制,因为之后将无法再次查看。", + "personalAccess": "此密钥授予对您个人工作流和文件的访问权限。创建后立即复制,因为之后将无法再次查看。", + "onlyTimeYouWillSee": "这是您唯一一次看到完整密钥的机会。请复制并安全存储。", + "unableToDetermineWorkspace": "无法确定工作区。请刷新页面后重试。", + "workspacePermissions": "您需要编辑或管理员权限才能管理工作区API密钥。" + }, + "dialogs": { + "createTitle": "创建{{scope}}API密钥", + "createNameLabel": "名称", + "createNamePlaceholder": "例如:Production MCP Server", + "createButton": "创建密钥", + "newKeyTitle": "您的{{scope}}API密钥", + "newKeyDescription": "这是您唯一一次看到完整密钥的机会。请复制并安全存储。", + "deleteTitle": "删除{{scope}}API密钥?", + "deleteDescription": "这将立即撤销使用此密钥的所有集成的访问权限。", + "deletePrompt": "输入{{name}}以确认。", + "deletePlaceholder": "API密钥名称", + "cancel": "取消", + "deleteButton": "删除密钥", + "copyToClipboard": "复制到剪贴板" + } + }, + "integrations": { + "title": "集成", + "searchPlaceholder": "搜索集成...", + "successMessage": "账户连接成功!", + "actionRequired": { + "title": "需要操作:", + "description": "请连接您的账户以启用所请求的功能。所需服务已在下方高亮显示。", + "button": "前往服务" + }, + "otherServices": "其他服务", + "connect": "连接", + "disconnect": "断开连接", + "emptyState": { + "noConnectible": "未配置可连接的集成。", + "noSearchMatches": "未找到与“{{query}}”匹配的服务。" + }, + "errors": { + "loadAvailability": "加载提供商可用性失败", + "oauth": "账户连接失败。请重试。" + } + }, + "files": { + "title": "文件", + "searchPlaceholder": "搜索文件...", + "upload": { + "idle": "上传文件", + "uploading": "正在上传...", + "uploadingWithCount": "正在上传 {{completed}}/{{total}}...", + "button": "上传文件" + }, + "headers": { + "name": "名称", + "size": "大小", + "uploaded": "上传时间", + "actions": "操作" + }, + "emptyState": { + "title": "尚未上传文件", + "description": "上传 PDF、文档、电子表格或演示文稿,为工作区提供支持。", + "button": "上传文件" + }, + "searchEmpty": { + "title": "没有文件匹配搜索", + "description": "尝试其他关键词,或清空搜索输入框。" + }, + "actions": { + "download": "下载", + "delete": "删除" + }, + "deleteDialog": { + "title": "删除文件?", + "descriptionWithName": "删除“{{name}}”将永久将其从此工作区移除。", + "description": "删除此文件将永久将其从此工作区移除。", + "warning": "此操作无法撤消。", + "cancel": "取消", + "confirm": "删除", + "deleting": "正在删除..." + } + }, + "userMenu": { + "accountDetail": "账户详情", + "helpSupport": "帮助与支持", + "serviceApiKeys": "服务 API 密钥", + "subscription": "订阅", + "manageBilling": "管理账单", + "openingBilling": "正在打开账单…", + "teamManagement": "团队管理", + "singleSignOn": "单点登录", + "logOut": "退出登录", + "loggingOut": "正在退出登录…", + "billingPortalSelectOrganization": "选择一个组织以管理账单。", + "billingPortalFailed": "打开账单门户失败", + "themeLabel": "主题:{{theme}}", + "languageLabel": "语言", + "themeOptions": { + "light": "浅色", + "system": "系统", + "dark": "深色" + }, + "defaultAvatarAlt": "默认头像" + }, + "settingsModal": { + "titles": { + "account": "账号设置", + "service": "服务 API 密钥", + "subscription": "订阅", + "team": "团队管理", + "sso": "单点登录", + "help": "帮助与支持" + }, + "common": { + "cancel": "取消" + }, + "account": { + "profilePicture": "头像", + "profilePictureAlt": "头像", + "dropImage": "拖放图片或点击上传", + "imageHint": "PNG 或 JPG,最大 5MB", + "profileDetails": "个人资料", + "profileDetailsDescription": "更新姓名并管理访问权限。", + "fullName": "全名", + "emailAddress": "邮箱地址", + "emailHint": "邮箱更改需联系支持团队处理。", + "passwordReset": "重置密码", + "passwordResetDescription": "我们会向您的邮箱发送安全链接。", + "sendLink": "发送链接", + "sending": "发送中…", + "saveName": "保存姓名", + "cancelEditingName": "取消编辑姓名", + "editName": "编辑姓名", + "privacy": "隐私", + "privacyDescription": "管理数据收集方式。", + "telemetry": { + "label": "允许匿名遥测", + "tooltipLabel": "了解遥测数据收集详情", + "tooltipBody": "我们收集有关功能使用、性能和错误的匿名数据,以改进应用程序。", + "body": "我们使用 OpenTelemetry 收集匿名使用数据以改进 TradingGoose。所有数据的收集均遵循我们的隐私政策,您可以随时选择退出。此设置适用于您所有设备上的账户。" + }, + "status": { + "profileSaved": "个人资料已保存。", + "nameRequired": "请提供姓名。", + "saveError": "无法保存个人资料设置。", + "nameRequiredValidation": "姓名是必填项", + "profilePictureUpdateError": "更新个人资料图片失败", + "profilePictureRemoveError": "移除个人资料图片失败", + "unableToUpdateProfilePicture": "无法更新个人资料图片。", + "failedUpdateName": "更新名称失败", + "unableToUpdateName": "无法更新名称,请重试。", + "noEmail": "未找到此账户的邮箱地址。", + "passwordResetSent": "密码重置链接已发送到您的收件箱。", + "passwordResetFailed": "无法发送密码重置邮件。" + } + }, + "help": { + "requestType": "请求", + "requestTypePlaceholder": "选择请求类型", + "requestTypes": { + "bug": "Bug 报告", + "feedback": "反馈", + "feature_request": "功能请求", + "other": "其他" + }, + "subject": "主题", + "subjectPlaceholder": "简要描述您的请求", + "message": "消息", + "messagePlaceholder": "请提供有关您请求的详细信息...", + "attachments": "附加图片(可选)", + "dropImages": "将图片拖放到此处!", + "dropImagesBrowse": "将图片拖放到此处或点击浏览", + "imageHint": "JPEG、PNG、WebP、GIF(每张最大20MB)", + "uploadedImages": "已上传的图片", + "cancel": "取消", + "submit": "提交", + "submitting": "正在提交...", + "processing": "正在处理图片...", + "success": "成功", + "error": "错误", + "errorMessages": { + "subjectRequired": "主题为必填项", + "messageRequired": "消息为必填项", + "requestTypeRequired": "请选择请求类型", + "fileTooLarge": "文件 {{name}} 过大,最大支持 20MB。", + "unsupportedFormat": "文件 {{name}} 格式不支持,请使用 JPEG、PNG、WebP 或 GIF 格式。", + "processing": "处理图片时出现错误,请重试。", + "submitFailed": "提交帮助请求失败", + "unknown": "发生未知错误" + } + }, + "service": { + "copilot": { + "title": "Copilot", + "description": "生成 Copilot API 访问密钥。" + }, + "market": { + "title": "市场", + "description": "生成市场 API 访问密钥。" + }, + "create": "创建", + "noKeys": "暂无 API 密钥", + "generateSuccessTitle": "您的 API 密钥已创建", + "generateSuccessDescription": "这是您唯一一次看到您的 API 密钥。请立即复制并安全存储。", + "copyToClipboard": "复制到剪贴板", + "deleteTitle": "删除 API 密钥?", + "deleteDescription": "删除此 API 密钥将立即撤销所有使用该密钥的集成的访问权限。此操作无法撤销。", + "cancel": "取消", + "delete": "删除" + }, + "subscription": { + "titles": { + "manage": "管理订阅", + "restore": "恢复订阅", + "upgrade": "升级", + "increaseLimit": "增加限额", + "nextBillingDate": "下次计费日期", + "usageNotifications": "用量通知", + "billingOwner": "计费所有者", + "organizationUsage": "组织用量", + "custom": "自定义", + "seats": "席位" + }, + "seatsText": "{{count}} 个席位", + "descriptions": { + "manage": "打开 Stripe Billing Portal 以取消、恢复或更新你的订阅。", + "usageNotifications": "当用量达到计费警告阈值时,通过邮件通知我。", + "customPlan": "联系你的客户团队以修改计费层级和用量限制。", + "teamMemberView": "联系你的团队管理员以增加限制。" + }, + "limit": { + "save": "保存限制", + "edit": "编辑限制" + }, + "billingOwner": { + "title": "计费所有者", + "description": "选择此工作区的计费所有者。", + "error": "错误", + "ownerLabel": "所有者", + "selectPlaceholder": "选择计费所有者", + "organization": "组织", + "billingNotice": "更改计费所有者会影响此工作区的付款方。", + "noActiveOrganization": "没有活跃组织可用于计费所有者。", + "invalidSelection": "计费所有者选择无效。", + "failedToUpdate": "更新计费所有者失败。" + }, + "actions": { + "manage": "管理", + "contact": "联系", + "upgradeTo": "升级到{{name}}" + }, + "badges": { + "resolvePayment": "处理付款", + "addPaymentMethod": "添加付款方式", + "activatePayg": "激活 PAYG", + "increaseLimit": "提高限额", + "manageBilling": "管理计费" + }, + "errors": { + "openBillingPortal": "打开计费门户失败", + "unknown": "发生未知错误", + "selectOrganization": "请选择一个组织以管理计费。", + "activatePayg": "激活 PAYG 失败", + "loadBillingData": "加载计费数据失败。" + } + }, + "team": { + "error": "错误", + "defaultTeamName": "{{name}}的团队", + "billingHowWorksSeatCost": "{{seats}}个席位{{plural}}每月花费${{amount}}。", + "billingHowWorksUsageTracked": "使用情况在组织内所有工作区中跟踪。", + "billingHowWorksIncreaseLimit": "当组织需要更多容量时,提高限额。", + "billingHowWorksOverage": "超额按当前套餐计费。", + "howBillingWorks": "团队计费说明", + "usageNote": "注意:", + "usageNoteBody": "用户一次只能属于一个组织。加入另一个组织前必须先离开当前组织。", + "teamId": "团队ID:", + "created": "创建时间:", + "yourRole": "您的角色:", + "upgradeToCreateTeam": "升级以创建团队", + "upgradeToCreateTeamDescription": "升级到组织层级以创建团队工作区。", + "openSubscriptionSettings": "打开订阅设置", + "createYourTeamWorkspace": "创建您的团队工作区", + "createYourTeamWorkspaceDescription": "创建一个组织以与您的团队协作。", + "teamName": "团队名称", + "teamNamePlaceholder": "我的团队", + "teamUrl": "团队URL", + "teamSlugPlaceholder": "my-team", + "createTeamWorkspace": "创建团队工作区", + "noTeamSubscriptionFound": "未找到团队订阅", + "subscriptionMayNeedTransfer": "您的订阅可能需要转移到此组织。", + "setUpTeamSubscription": "设置团队订阅", + "seats": "席位", + "pricePerSeat": "({{price}}/月/席)", + "used": "已使用 {{count}} 个", + "total": "共 {{count}} 个", + "removeSeat": "移除席位", + "addSeat": "添加席位", + "seat": "席位", + "numberOfSeats": "席位数量", + "yourTeamWillHave": "您的团队将拥有 {{count}} 个{{seatWord}},每月总计 ${{cost}} 推理点数。", + "minimumSeatsNoMax": "最少 {{minimum}} 个席位。此层级无席位上限。", + "chooseBetweenSeats": "请为此层级选择 {{minimum}} 到 {{maximum}} 个席位。", + "currentSeats": "当前席位:", + "newSeats": "新席位:", + "monthlyCostChange": "每月费用变化:", + "loading": "加载中…", + "reactivateSubscription": "要更新席位,请前往订阅 > 管理 > 保留订阅以重新激活", + "leaveOrganization": "离开组织", + "removeTeamMember": "移除团队成员", + "leaveOrganizationDescription": "确定要离开此组织吗?您将失去对所有团队资源的访问权限。", + "removeMemberDescription": "确定要将 {{name}} 从团队中移除吗?", + "alsoReduceSeatCount": "同时在我的订阅中减少席位数量", + "reduceSeatCountDescription": "如果选中,您的团队席位数量将减少 1,从而降低您的月度账单。", + "thisActionCannotBeUndone": "此操作无法撤销。", + "cancel": "取消", + "remove": "移除", + "inviteUnavailableMessage": "需要有效的组织订阅才能邀请团队成员。", + "yourself": "您自己", + "thisMember": "此成员", + "noPublicAdjustableTier": "未配置公开的可调整组织层级", + "addSeats": { + "title": "添加团队席位", + "description": "每个席位每月费用为 ${{price}},并提供 ${{price}} 的月度推理额度。调整您团队的许可席位数量。", + "confirm": "更新席位" + }, + "billing": { + "title": "工作空间计费", + "description": "将工作空间分配给组织或所有者。", + "organizationBillingRequired": "需要组织计费才能管理工作空间计费。", + "organizationBilledTitle": "由组织计费", + "organizationBilledEmpty": "没有工作空间由组织计费。", + "availableOwnerBilledTitle": "所有者计费的工作空间", + "availableOwnerBilledEmpty": "没有可用的所有者计费工作空间。", + "returnToOwner": "返回所有者计费", + "billToOrganization": "由组织计费", + "organization": "组织", + "ownerBilling": "所有者计费", + "ownerLabel": "所有者:" + }, + "members": { + "title": "团队成员", + "empty": "暂无团队成员。", + "sharedUsage": "共享用量:${{amount}}", + "pending": "待处理", + "billing": "计费", + "usage": "用量", + "sharedPool": "共享池", + "unknown": "未知", + "removeMemberTooltip": "移除成员", + "cancelling": "正在取消...", + "cancelInvitationTooltip": "取消邀请", + "leaveOrganization": "离开组织" + }, + "invitation": { + "title": "邀请团队成员", + "description": "邀请用户加入您的组织,并选择工作空间访问权限。", + "emailPlaceholder": "name@example.com", + "hideWorkspaces": "隐藏工作空间", + "addWorkspaces": "添加工作空间", + "invite": "邀请", + "noSeats": "无可用席位", + "unavailable": "邀请不可用", + "workspaceAccess": "工作空间访问", + "optional": "可选", + "selected": "已选择 {{count}} 个工作空间{{plural}}", + "grantAccess": "授予特定工作空间的访问权限,并选择权限级别。", + "noWorkspacesAvailable": "没有可用的工作空间。", + "needAdminAccess": "您需要管理员权限才能分配工作空间。", + "owner": "所有者", + "sentSuccess": "邀请已发送。", + "sentSuccessWithAccess": "已发送邀请,可访问 {{count}} 个工作空间{{plural}}。", + "invalidEmail": "请输入有效的电子邮件地址。", + "permissions": { + "read": { + "label": "读取", + "description": "可以查看工作空间内容。" + }, + "write": { + "label": "写入", + "description": "可以编辑工作空间内容。" + }, + "admin": { + "label": "管理员", + "description": "可以管理工作空间设置。" + } + } + } + }, + "sso": { + "providerStatus": "单点登录提供方", + "issuerUrl": "颁发者 URL", + "providerId": "提供方 ID", + "callbackUrl": "回调 URL", + "callbackUrlHelp": "请在身份提供商设置中使用此回调 URL。", + "providerType": "提供方类型", + "providerTypeDescriptions": { + "oidc": "OpenID Connect(Okta、Azure AD、Auth0 等)", + "saml": "安全断言标记语言(ADFS、Shibboleth 等)" + }, + "selectProviderHelp": "从可信提供方列表中选择预配置的提供方 ID。", + "issuerUrlHelp": "使用身份提供商提供的颁发者 URL。", + "providerIdPlaceholder": "选择提供商 ID", + "issuerUrlPlaceholder": "输入颁发者 URL", + "domain": "域名", + "domainPlaceholder": "输入域名", + "clientId": "客户端 ID", + "clientIdPlaceholder": "输入客户端 ID", + "clientSecret": "客户端密钥", + "clientSecretPlaceholder": "输入客户端密钥", + "scopes": "范围", + "selectProviderId": "选择提供商 ID", + "copyCallbackUrl": "复制回调 URL", + "showClientSecret": "显示客户端密钥", + "hideClientSecret": "隐藏客户端密钥", + "scopesPlaceholder": "openid,profile,email", + "scopesDescription": "以逗号分隔的 OIDC 范围列表。", + "entryPoint": "入口点 URL", + "entryPointPlaceholder": "输入入口点 URL", + "entryPointDescription": "提供身份提供商的 SAML 入口点 URL。", + "certificate": "身份提供商证书", + "certificatePlaceholder": "-----BEGIN CERTIFICATE-----", + "certificateDescription": "粘贴身份提供商提供的证书。", + "advancedOptions": "高级 SAML 选项", + "audience": "受众(实体 ID)", + "audiencePlaceholder": "输入受众", + "audienceDescription": "SAML受众限制,可选,默认为应用URL。", + "callbackUrlOverride": "回调URL覆盖", + "callbackUrlPlaceholder": "输入回调URL", + "callbackUrlDescription": "自定义SAML回调URL,可选,如果留空则自动生成。", + "requireSignedAssertions": "要求签名SAML断言", + "metadataXml": "身份提供商元数据XML", + "metadataPlaceholder": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\">\n ...\n</md:EntityDescriptor>", + "metadataDescription": "粘贴来自身份提供商的完整IDP元数据XML,用于高级配置。", + "loadingProviders": "正在加载提供商...", + "noProviders": "未找到提供商。", + "save": "保存更改", + "create": "配置SSO提供商", + "configureProvider": "配置SSO提供商", + "saving": "保存中...", + "configuring": "配置中...", + "selectOrganization": "您必须属于某个组织才能配置单点登录。", + "onlyAdmins": "只有组织所有者和管理员才能配置单点登录设置。", + "disabledTier": "此计费等级未启用单点登录。", + "providerLoadError": "加载SSO提供商配置失败", + "providerError": "配置SSO提供商失败", + "reloadError": "重新加载SSO提供商失败", + "validation": { + "fieldRequired": "{{field}}为必填项。", + "providerIdRequired": "提供商ID为必填项。", + "providerIdPattern": "仅允许使用字母、数字和短横线。", + "issuerUrlRequired": "签发者URL为必填项。", + "issuerUrlHttps": "颁发者 URL 必须使用 HTTPS。", + "issuerUrlValid": "输入有效的颁发者 URL,例如 https://your-identity-provider.com/oauth2/default", + "domainRequired": "域是必填项。", + "domainNoProtocol": "请勿包含协议(https://)。", + "domainValid": "输入有效的域,例如 your-domain.identityprovider.com", + "clientIdRequired": "客户端 ID 是必填项。", + "clientSecretRequired": "客户端密钥是必填项。", + "scopesRequired": "OIDC 提供商需要作用域。", + "entryPointRequired": "SAML 提供商需要入口点 URL。", + "certificateRequired": "证书是必填项。", + "providerLoadFailed": "加载 SSO 提供商失败", + "providerLoadFailedGeneric": "加载 SSO 提供商配置失败" + } + } + }, + "widgets": { + "selector": { + "categories": { + "list": "列表", + "editor": "编辑器", + "utility": "工具" + }, + "selectWidget": "选择组件", + "widgetSelectionUnavailable": "组件选择不可用" + }, + "titles": { + "empty": "空白区域", + "data_chart": "市场数据", + "workflow_list": "工作流", + "editor_workflow": "工作流编辑器", + "workflow_chat": "工作流聊天", + "workflow_console": "工作流控制台", + "copilot": "协作助手", + "list_indicator": "指标", + "list_mcp": "MCP 服务器", + "editor_indicator": "指标编辑器", + "editor_mcp": "MCP 编辑器", + "list_custom_tool": "自定义工具", + "editor_custom_tool": "自定义工具编辑器", + "list_skill": "技能", + "editor_skill": "技能编辑器", + "workflow_variables": "工作流变量", + "watchlist": "观察列表" + }, + "empty": { + "noWidgetSelected": "未选择组件", + "emptyWidget": "空组件", + "noWidgetDescription": "从图库中选择一个组件来开始使用此面板。", + "emptyWidgetDescription": "此组件当前为空,请选择另一个组件继续。", + "chooseWidget": "选择组件", + "surfaceTitle": "空白区域", + "surfaceDescription": "当面板没有分配组件时显示的占位状态。" + }, + "workflowDropdown": { + "selectWorkspaceFirst": "请先选择一个工作空间。", + "unableToLoad": "无法加载工作流", + "workflowSelectionUnavailable": "工作流选择不可用", + "selectWorkflow": "选择工作流", + "searchPlaceholder": "搜索工作流...", + "failedToLoad": "无法加载工作流", + "retry": "重试", + "loading": "正在加载工作流...", + "noWorkflowsAvailable": "当前还没有可用的工作流。", + "noWorkflowsFound": "未找到工作流。", + "untitledWorkflow": "未命名工作流" + }, + "mcpDropdown": { + "selectWorkspaceFirst": "请先选择一个工作空间。", + "unableToLoad": "无法加载 MCP 服务器", + "mcpSelectionUnavailable": "MCP 选择不可用", + "selectMcpServer": "选择 MCP 服务器", + "searchPlaceholder": "搜索服务器...", + "failedToLoad": "无法加载 MCP 服务器", + "retry": "重试", + "loading": "正在加载 MCP 服务器...", + "noServersAvailable": "当前还没有可用的 MCP 服务器。", + "noServersFound": "未找到服务器。", + "unnamedServer": "未命名服务器" + }, + "console": { + "selectWorkspace": "选择一个工作空间以加载工作流。", + "noWorkflows": "此工作空间中没有可用的工作流。", + "filters": "筛选", + "status": "状态", + "error": "错误", + "info": "信息", + "blocks": "块", + "sortByTime": "按时间排序", + "structuredView": "结构化视图", + "toggleStructuredView": "切换结构化视图", + "wrapText": "自动换行", + "toggleWrapText": "切换自动换行", + "downloadConsoleCsv": "下载控制台 CSV", + "downloadCsv": "下载 CSV", + "clearConsole": "清空控制台", + "noResults": "未找到与 \"{{query}}\" 匹配的结果" + }, + "mcpEditor": { + "selectWorkspaceToEdit": "选择一个工作空间来编辑 MCP 服务器。", + "selectServerToEdit": "选择一个 MCP 服务器进行编辑。", + "saveDraftHint": "保存此草稿后即可启用连接测试、工具刷新和标准化重载。", + "failedToLoadMcpServers": "无法加载 MCP 服务器。", + "tools": "工具", + "saveRequired": "需要保存", + "noToolsDiscovered": "尚未发现工具。", + "saveThisServerToRefreshAndInspectDiscoveredMcpTools": "保存此服务器以刷新并查看已发现的 MCP 工具。", + "refreshTools": "刷新工具", + "testConnection": "测试连接", + "resetForm": "重置表单", + "saveServer": "保存服务器", + "clearSelection": "清除选择", + "selectServer": "选择服务器", + "serverNameRequired": "需要服务器名称。", + "failedToRefreshMcpServer": "无法刷新 MCP 服务器。", + "failedToSaveMcpServer": "无法保存 MCP 服务器。", + "toolCount": "共 {{count}} 个", + "loading": "加载中...", + "connected": "已连接", + "error": "错误", + "draft": "草稿", + "disconnected": "未连接", + "unnamedServer": "未命名服务器", + "updated": "已更新 {{time}} 前", + "toolsRefreshed": "工具已刷新 {{time}} 前", + "lastConnected": "上次连接 {{time}} 前", + "lastError": "上次错误" + }, + "triggerList": { + "coreTriggers": "核心触发器", + "integrationTriggers": "集成触发器", + "searchPlaceholder": "搜索触发器", + "openTriggerList": "点击添加触发器", + "close": "关闭", + "noResults": "未找到与 \"{{query}}\" 匹配的结果" + }, + "workflowToolbar": { + "selectWorkspace": "选择一个工作空间来浏览模块", + "blocks": "模块", + "tools": "工具", + "triggers": "触发器", + "special": "特殊", + "browseLabel": "浏览{{label}}", + "searchPlaceholder": "搜索{{label}}...", + "noResults": "未找到{{label}}。" + }, + "workflowLabels": { + "systemPrompt": "系统提示词", + "userPrompt": "用户提示词", + "model": "模型", + "temperature": "温度", + "apiKey": "API 密钥", + "skills": "技能", + "tools": "工具", + "responseFormat": "响应格式", + "reasoningEffort": "推理力度", + "verbosity": "详细程度", + "configured": "已配置", + "value": "值", + "items": "项目", + "fields": "字段", + "object": "对象", + "block": "块", + "type": "类型", + "none": "无", + "noValuesToDisplay": "没有可显示的值。", + "error": "错误", + "if": "如果", + "else": "否则", + "elseIf": "否则如果", + "addSkill": "添加技能", + "searchSkills": "搜索技能...", + "chooseModel": "选择模型", + "lite": "轻量", + "anthropic": "Anthropic", + "openai": "OpenAI", + "nextStep": "下一步", + "locked": "已锁定", + "deployed": "已部署", + "deployedWithVersion": "已部署(v{{version}})", + "notDeployed": "未部署", + "disabled": "已禁用", + "removeSkill": "移除 {{name}}", + "currentWorkflow": "当前工作流", + "currentSkill": "当前技能", + "currentTool": "当前工具", + "currentIndicator": "当前指标", + "currentMcpServer": "当前 MCP 服务器", + "workflows": "工作流", + "customTools": "自定义工具", + "indicators": "指标", + "mcpServers": "MCP 服务器", + "allWorkflows": "所有工作流" + }, + "workflowEditor": { + "previewInspector": "预览检查器", + "selectBlockToViewPreviewDetails": "选择一个块以查看其预览详情。", + "nodeNotFound": "未找到节点", + "selectedNodeUnavailable": "所选节点已不可用。", + "missingBlockConfiguration": "缺少 `{{type}}` 的块配置。", + "saveName": "保存名称", + "renameNode": "重命名节点", + "loopTypeLabel": "循环类型", + "parallelTypeLabel": "并行类型", + "selectType": "选择类型", + "forLoop": "For 循环", + "forEachLoop": "逐项循环", + "whileLoop": "While 循环", + "doWhileLoop": "Do While 循环", + "parallelCount": "并行计数", + "parallelEach": "并行遍历", + "loopIterations": "循环迭代次数", + "parallelExecutions": "并行执行次数", + "whileCondition": "While 条件", + "collectionItems": "集合项", + "parallelItems": "并行项", + "enterValueBetween": "请输入 1 到 {{max}} 之间的值", + "hideAdditionalFields": "隐藏附加字段", + "showAdditionalFields": "显示附加字段", + "additionalFields": "附加字段", + "triggerNoEditableFields": "此触发器在面板中没有可编辑字段。", + "blockNoEditableFields": "此块没有可编辑字段。", + "requiredField": "此字段为必填项", + "invalidJson": "JSON 无效", + "unknownInputType": "未知输入类型:{{type}}", + "loop": "循环", + "parallel": "并行", + "start": "开始", + "end": "结束" + }, + "apiKey": { + "apiKey": "API 密钥", + "apiKeyType": "API 密钥类型", + "apiKeyName": "API 密钥名称", + "personal": "个人", + "workspace": "工作空间", + "selectApiKey": "选择 API 密钥", + "myApiKey": "我的 API 密钥", + "ownerIsBilledForUsage": "使用费用由所有者承担", + "keyOwnerIsBilled": "密钥所有者承担费用", + "createNew": "创建新密钥", + "loadingApiKeys": "正在加载 API 密钥...", + "selectAnApiKey": "选择一个 API 密钥", + "noApiKeysAvailable": "没有可用的 API 密钥", + "createNewApiKey": "创建新的 API 密钥", + "workspaceAccess": "此密钥可访问此工作空间中的所有工作流。创建后请立即复制,因为之后将无法再次查看。", + "personalAccess": "此密钥可访问你的个人工作流。创建后请立即复制,因为之后将无法再次查看。", + "apiKeyHasBeenCreated": "你的 API 密钥已创建", + "onlyTimeYouWillSeeYourApiKey": "这是你唯一一次看到该 API 密钥。", + "copyItNowAndStoreItSecurely": "请立即复制并妥善保存。", + "copyToClipboard": "复制到剪贴板", + "enterName": "请输入 API 密钥名称", + "failedToCreate": "创建 API 密钥失败", + "create": "创建", + "creating": "正在创建...", + "cancel": "取消", + "workspaceLabel": "工作空间", + "personalLabel": "个人" + }, + "entityEditor": { + "undo": "撤销", + "redo": "重做" + }, + "pairColor": { + "selectionUnavailable": "颜色选择不可用", + "selectWidgetColor": "选择组件颜色", + "unlinked": "未关联", + "red": "红色", + "orange": "橙色", + "blue": "蓝色", + "green": "绿色", + "purple": "紫色" + }, + "deployment": { + "adminPermissionsRequiredToDeployWorkflows": "部署工作流需要管理员权限", + "deploying": "正在部署...", + "workflowChangesDetected": "检测到工作流变更", + "deploymentSettings": "部署设置", + "deployWorkflow": "部署工作流", + "deployApi": "部署 API", + "needsRedeployment": "需要重新部署", + "deployWorkflowTitle": "部署工作流", + "active": "已激活", + "close": "关闭", + "deploymentError": "部署错误", + "expandSidebar": "展开侧边栏", + "collapseSidebar": "折叠侧边栏", + "selectSharedDeploymentApiKeyInBillingBeforeDeployingThisApiTrigger": "在部署此 API 触发器之前,请先在计费中选择一个共享部署 API 密钥。", + "thisApiTriggerUsesTheSharedDeploymentApiKey": "此 API 触发器使用共享部署 API 密钥 {displayKey}。", + "thisApiTriggerUsesTheSharedDeploymentApiKeySelectedInBilling": "此 API 触发器使用在计费中选择的共享部署 API 密钥。", + "thisApiTriggerWillUseTheSharedDeploymentApiKeyCurrentlySelectedInBilling": "此 API 触发器将使用当前在计费中选择的共享部署 API 密钥。" + }, + "workflowCreateMenu": { + "createButtonTooltip": "创建文件夹或工作流", + "selectWorkspaceTooltip": "选择一个工作空间来创建工作流", + "createWorkflow": "新建工作流", + "createFolder": "新建文件夹", + "importWorkflow": "导入工作流", + "creating": "正在创建...", + "importing": "正在导入..." + } + }, + "layoutTabs": { + "createNewLayout": "创建新布局" + } + } +} diff --git a/apps/tradinggoose/i18n/navigation.ts b/apps/tradinggoose/i18n/navigation.ts new file mode 100644 index 000000000..e90834111 --- /dev/null +++ b/apps/tradinggoose/i18n/navigation.ts @@ -0,0 +1,4 @@ +import { createNavigation } from 'next-intl/navigation' +import { routing } from './routing' + +export const { Link, usePathname, useRouter, redirect, getPathname } = createNavigation(routing) diff --git a/apps/tradinggoose/i18n/public-copy.test.ts b/apps/tradinggoose/i18n/public-copy.test.ts new file mode 100644 index 000000000..b5a099f10 --- /dev/null +++ b/apps/tradinggoose/i18n/public-copy.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { formatTemplate, getPublicCopy } from './public-copy' + +describe('public copy', () => { + it('loads translated locale files directly', () => { + expect(getPublicCopy('en').meta.landing.title).toContain('TradingGoose') + expect(getPublicCopy('es').blog.readTimeSuffix).toBe('min de lectura') + expect(getPublicCopy('zh-CN').meta.landing.seo.socialPreviewAlt).toContain('TradingGoose') + }) + + it('keeps zh-CN auth copy translated', () => { + const zhCopy = getPublicCopy('zh-CN') + const enCopy = getPublicCopy('en') + + expect(zhCopy.auth.common.signIn).toBe('登录') + expect(zhCopy.auth.common.signUp).toBe('注册') + expect(zhCopy.auth.login.submit).toBe('登录') + expect(zhCopy.auth.signup.submit).toBe('创建账号') + expect(zhCopy.auth.waitlist.submit).toBe('申请访问') + expect(zhCopy.auth.note.waitlistApprovedEmail).toContain('等待名单') + expect(zhCopy.auth.common.signIn).not.toBe(enCopy.auth.common.signIn) + expect(zhCopy.auth.login.submit).not.toBe(enCopy.auth.login.submit) + }) + + it('includes translated verify-email auth copy', () => { + expect(getPublicCopy('en').auth.common.verifyEmail).toBe('Verify email') + expect(getPublicCopy('es').auth.common.verifyEmail).toBe('Verificar correo') + expect(getPublicCopy('zh-CN').auth.common.verifyEmail).toBe('验证邮箱') + }) + + 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('es').auth.verify.verifyButton).toBe('Verificar correo') + expect(getPublicCopy('es').auth.verify.errors.resendFailed).toContain('reenviar') + expect(getPublicCopy('zh-CN').auth.verify.instructionsWithoutService).toBe( + '请输入 6 位验证码以验证你的账号。' + ) + expect(getPublicCopy('zh-CN').auth.verify.yourEmail).toBe('你的邮箱') + }) + + it('includes localized workspace copy', () => { + expect(getPublicCopy('en').workspace.defaults.defaultLayoutName).toBe('Default Layout') + expect(getPublicCopy('zh-CN').workspace.defaults.newWorkspaceName).toBe('我的工作空间') + expect(getPublicCopy('en').workspace.naming.workspacePrefix).toBe('Workspace') + expect(getPublicCopy('es').workspace.naming.folderPrefix).toBe('Carpeta') + expect(getPublicCopy('en').workspace.nav.groups.workspace).toBe('Workspace') + expect(getPublicCopy('zh-CN').workspace.nav.groups.system).toBe('系统') + expect(getPublicCopy('en').workspace.userMenu.accountDetail).toBe('Account Detail') + expect(getPublicCopy('en').workspace.userMenu.helpSupport).toBe('Help & Support') + expect(getPublicCopy('es').workspace.userMenu.accountDetail).toBe('Detalles de la cuenta') + expect(getPublicCopy('es').workspace.userMenu.helpSupport).toBe('Ayuda y soporte') + expect(getPublicCopy('zh-CN').workspace.userMenu.accountDetail).toBe('账户详情') + expect(getPublicCopy('zh-CN').workspace.userMenu.helpSupport).toBe('帮助与支持') + expect(getPublicCopy('zh-CN').workspace.widgets.workflowLabels.systemPrompt).toBe( + '系统提示词' + ) + expect(getPublicCopy('es').workspace.widgets.workflowLabels.systemPrompt).toBe( + 'Prompt del sistema' + ) + expect(getPublicCopy('en').workspace.widgets.workflowLabels.tools).toBe('Tools') + expect(getPublicCopy('zh-CN').workspace.widgets.workflowLabels.tools).toBe('工具') + expect(getPublicCopy('en').workspace.widgets.workflowLabels.deployedWithVersion).toBe( + 'Deployed (v{{version}})' + ) + expect(getPublicCopy('en').workspace.knowledge.title).toBe('Knowledge') + expect(getPublicCopy('zh-CN').workspace.logs.title.logs).toBe('日志') + expect(getPublicCopy('en').workspace.widgets.selector.selectWidget).toBe('Select widget') + expect(getPublicCopy('es').workspace.widgets.workflowCreateMenu.createWorkflow).toBe( + 'Nuevo flujo' + ) + expect(getPublicCopy('zh-CN').workspace.widgets.workflowEditor.previewInspector).toBe( + '预览检查器' + ) + expect(getPublicCopy('en').workspace.widgets.pairColor.selectWidgetColor).toBe( + 'Select widget color' + ) + expect(getPublicCopy('zh-CN').workspace.widgets.apiKey.selectApiKey).toBe('选择 API 密钥') + }) + + it('includes localized SSO callback helper copy', () => { + expect(getPublicCopy('en').workspace.settingsModal.sso.callbackUrlHelp).toBe( + 'Use this callback URL in your identity provider settings.' + ) + expect(getPublicCopy('es').workspace.settingsModal.sso.callbackUrlHelp).toContain( + 'URL de callback' + ) + expect(getPublicCopy('zh-CN').workspace.settingsModal.sso.callbackUrlHelp).toContain( + '回调 URL' + ) + }) + + it('formats template placeholders', () => { + const copy = getPublicCopy('en') + + expect(formatTemplate(copy.blog.pageDescription, { count: 3 })).toContain('3 articles') + }) +}) diff --git a/apps/tradinggoose/i18n/public-copy.ts b/apps/tradinggoose/i18n/public-copy.ts new file mode 100644 index 000000000..be26bd015 --- /dev/null +++ b/apps/tradinggoose/i18n/public-copy.ts @@ -0,0 +1,32 @@ +import type { RegistrationMode } from '@/lib/registration/shared' +import enCopy from './messages/en.json' +import esCopy from './messages/es.json' +import zhCnCopy from './messages/zh-CN.json' +import { defaultLocale, type LocaleCode } from './utils' + +export type PublicCopy = typeof enCopy + +const PUBLIC_COPY = { + en: enCopy, + es: esCopy, + 'zh-CN': zhCnCopy, +} satisfies Record<LocaleCode, PublicCopy> + +export function getPublicCopy(locale: LocaleCode | string | undefined): PublicCopy { + return PUBLIC_COPY[(locale && locale in PUBLIC_COPY ? locale : defaultLocale) as LocaleCode] +} + +export function getPrimaryRegistrationLabel(copy: PublicCopy, mode: RegistrationMode) { + return copy.registration[mode].primary +} + +export function getAuthRegistrationLabel(copy: PublicCopy, mode: RegistrationMode) { + return copy.registration[mode].auth +} + +export function formatTemplate(template: string, values: Record<string, string | number>) { + return Object.entries(values).reduce( + (result, [key, value]) => result.replaceAll(`{{${key}}}`, String(value)), + template + ) +} diff --git a/apps/tradinggoose/i18n/request.ts b/apps/tradinggoose/i18n/request.ts new file mode 100644 index 000000000..bc7640d48 --- /dev/null +++ b/apps/tradinggoose/i18n/request.ts @@ -0,0 +1,13 @@ +import { getRequestConfig } from 'next-intl/server' +import { getPublicCopy } from './public-copy' +import { defaultLocale, isLocaleCode } from './utils' + +export default getRequestConfig(async ({ requestLocale }) => { + const requestedLocale = await requestLocale + const locale = requestedLocale && isLocaleCode(requestedLocale) ? requestedLocale : defaultLocale + + return { + locale, + messages: getPublicCopy(locale), + } +}) diff --git a/apps/tradinggoose/i18n/routing.ts b/apps/tradinggoose/i18n/routing.ts new file mode 100644 index 000000000..c20e5ce31 --- /dev/null +++ b/apps/tradinggoose/i18n/routing.ts @@ -0,0 +1,16 @@ +import { defineRouting } from 'next-intl/routing' +import { defaultLocale, locales } from './utils' + +export const routing = defineRouting({ + locales, + defaultLocale, + localePrefix: { + mode: 'as-needed', + prefixes: { + 'zh-CN': '/zh', + }, + }, + localeDetection: false, +}) + +export type AppLocale = (typeof locales)[number] diff --git a/apps/tradinggoose/i18n/utils.test.ts b/apps/tradinggoose/i18n/utils.test.ts new file mode 100644 index 000000000..8515702e8 --- /dev/null +++ b/apps/tradinggoose/i18n/utils.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + buildLocaleRequestHeaders, + getOpenGraphLocale, + localizeHref, + localizePathname, + stripLocaleFromPathname, +} from './utils' + +describe('i18n utils', () => { + it('strips locale prefixes from localized paths', () => { + expect(stripLocaleFromPathname('/es/blog/trading-signals')).toEqual({ + locale: 'es', + pathname: '/blog/trading-signals', + }) + }) + + it('defaults to English for unprefixed paths', () => { + expect(stripLocaleFromPathname('/blog/trading-signals')).toEqual({ + locale: 'en', + pathname: '/blog/trading-signals', + }) + }) + + it('localizes pathnames without dropping the current slug', () => { + expect(localizePathname('zh-CN', '/blog/trading-signals')).toBe( + '/zh/blog/trading-signals' + ) + expect(localizePathname('zh-CN', '/blog/trading-signals')).not.toContain('/zh-CN') + expect(localizePathname('en', '/blog/trading-signals')).toBe('/blog/trading-signals') + }) + + it('preserves query strings on already localized URLs', () => { + expect(localizePathname('zh-CN', '/blog/trading-signals?from=nav')).toBe( + '/zh/blog/trading-signals?from=nav' + ) + }) + + it('localizes internal hrefs without double-prefixing locale segments', () => { + expect(localizeHref('zh-CN', '/workspace/ws-1/dashboard?layoutId=layout-1')).toBe( + '/zh/workspace/ws-1/dashboard?layoutId=layout-1' + ) + expect(localizeHref('zh-CN', '/zh/login?reauth=1')).toBe('/zh/login?reauth=1') + expect(localizeHref('en', '/zh/workspace')).toBe('/workspace') + }) + + it('builds locale-aware request headers', () => { + const headers = buildLocaleRequestHeaders('zh-CN', { + 'Content-Type': 'application/json', + }) + + expect(headers.get('content-type')).toBe('application/json') + expect(headers.get('x-next-intl-locale')).toBe('zh-CN') + }) + + it('maps Open Graph locales using canonical regional codes', () => { + expect(getOpenGraphLocale('es')).toBe('es_ES') + expect(getOpenGraphLocale('zh-CN')).toBe('zh_CN') + }) +}) diff --git a/apps/tradinggoose/i18n/utils.ts b/apps/tradinggoose/i18n/utils.ts new file mode 100644 index 000000000..6351e481d --- /dev/null +++ b/apps/tradinggoose/i18n/utils.ts @@ -0,0 +1,88 @@ +export type LocaleCode = 'en' | 'es' | 'zh-CN' + +export const locales = ['en', 'es', 'zh-CN'] as const +export const defaultLocale: LocaleCode = 'en' +const DOCS_BASE_URL = 'https://docs.tradinggoose.ai' + +const PUBLIC_LOCALE_PATH_SEGMENTS: Record<LocaleCode, string> = { + en: 'en', + es: 'es', + 'zh-CN': 'zh', +} + +const OPEN_GRAPH_LOCALE_MAP: Record<LocaleCode, string> = { + en: 'en_US', + es: 'es_ES', + 'zh-CN': 'zh_CN', +} + +export function getLocalePathSegment(locale: LocaleCode) { + return PUBLIC_LOCALE_PATH_SEGMENTS[locale] +} + +export function isLocaleCode(value: string): value is LocaleCode { + return (locales as readonly string[]).includes(value) +} + +export function stripLocaleFromPathname(pathname: string): { locale: LocaleCode; pathname: string } { + const segments = pathname.split('/').filter(Boolean) + const firstSegment = segments[0] + + if (firstSegment) { + const locale = locales.find((candidate) => getLocalePathSegment(candidate) === firstSegment) + + if (locale) { + const stripped = `/${segments.slice(1).join('/')}`.replace(/\/+$/, '') + return { + locale, + pathname: stripped || '/', + } + } + } + + return { + locale: defaultLocale, + pathname: pathname || '/', + } +} + +export function localizePathname(locale: LocaleCode, pathname: string) { + const normalized = pathname === '/' ? '/' : pathname.replace(/\/+$/, '') + const localeSegment = getLocalePathSegment(locale) + + if (locale === defaultLocale) { + return normalized + } + + return normalized === '/' ? `/${localeSegment}` : `/${localeSegment}${normalized}` +} + +export function localizeHref(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 `${localizePathname(locale, pathname)}${parsedUrl.search}${parsedUrl.hash}` +} + +export function buildLocaleRequestHeaders(locale: LocaleCode, headers?: HeadersInit) { + const requestHeaders = new Headers(headers) + requestHeaders.set('x-next-intl-locale', locale) + + return requestHeaders +} + +export function localizeUrl(baseUrl: string, locale: LocaleCode, pathname: string) { + return `${baseUrl}${localizePathname(locale, pathname)}` +} + +export function localizeDocsUrl(locale: LocaleCode, pathname = '/') { + return localizeUrl(DOCS_BASE_URL, locale, pathname) +} + +export function getOpenGraphLocale(locale: LocaleCode) { + return OPEN_GRAPH_LOCALE_MAP[locale] +} diff --git a/apps/tradinggoose/lib/auth.ts b/apps/tradinggoose/lib/auth.ts index 992372bfb..6ee30a844 100644 --- a/apps/tradinggoose/lib/auth.ts +++ b/apps/tradinggoose/lib/auth.ts @@ -18,7 +18,7 @@ import type { GenericOAuthConfig } from 'better-auth/plugins/generic-oauth' /** OAuth2 token type extracted from better-auth's GenericOAuthConfig */ type OAuthTokens = Parameters<NonNullable<GenericOAuthConfig['getUserInfo']>>[0] -import { eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { headers } from 'next/headers' import type Stripe from 'stripe' import { @@ -84,6 +84,7 @@ import { } from '@/lib/system-services/stripe-runtime' import { getResolvedSystemSettings } from '@/lib/system-settings/service' import { getBaseUrl } from '@/lib/urls/utils' +import { resolveAlpacaTradingBaseUrl } from '@/providers/trading/alpaca/config' import { SSO_TRUSTED_PROVIDERS } from './sso/consts' const logger = createLogger('Auth') @@ -164,6 +165,37 @@ function toSystemManagedGenericOAuthConfigs(configs: SystemManagedGenericOAuthCo return configs.map((config) => toSystemManagedGenericOAuthConfig(config)) } +function createAlpacaOAuthConfig( + providerId: 'alpaca-live' | 'alpaca-paper', + environment: 'live' | 'paper' +): SystemManagedGenericOAuthConfig { + return { + providerId, + authorizationUrl: 'https://app.alpaca.markets/oauth/authorize', + authorizationUrlParams: { env: environment }, + tokenUrl: 'https://api.alpaca.markets/oauth/token', + authentication: 'post', + scopes: getCanonicalScopesForProvider(providerId), + getUserInfo: async (tokens) => { + const response = await fetch(`${resolveAlpacaTradingBaseUrl(environment)}/v2/account`, { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + const data = await response.json() + return { + id: data.id, + name: data.account_number, + email: data.account_number, + image: '', + emailVerified: false, + } + }, + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/${providerId}`, + } +} + function toEnvBackedSocialProviderConfig<T extends EnvBackedSocialProviderConfig>( envKeys: { clientId: string @@ -426,6 +458,27 @@ export const auth = betterAuth({ account: { create: { after: async (account) => { + try { + await db + .delete(schema.account) + .where( + and( + eq(schema.account.userId, account.userId), + eq(schema.account.providerId, account.providerId), + ne(schema.account.id, account.id) + ) + ) + } catch (error) { + logger.error( + '[databaseHooks.account.create.after] Failed to remove older account rows', + { + accountId: account.id, + providerId: account.providerId, + error, + } + ) + } + if (!isMicrosoftProvider(account.providerId)) { return } @@ -632,31 +685,8 @@ export const auth = betterAuth({ }), genericOAuth({ config: toSystemManagedGenericOAuthConfigs([ - { - providerId: 'alpaca', - authorizationUrl: 'https://app.alpaca.markets/oauth/authorize', - tokenUrl: 'https://api.alpaca.markets/oauth/token', - scopes: ['account:write', 'trading', 'data'], - getUserInfo: async (tokens) => { - // Access provider-specific fields from raw token data - const options = { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - } - const response = await fetch('https://paper-api.alpaca.markets/v2/account', options) - const data = await response.json() - return { - id: data.id, - name: data.account_number, - email: data.account_number, - image: '', - emailVerified: false, - } - }, - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/alpaca`, - }, + createAlpacaOAuthConfig('alpaca-live', 'live'), + createAlpacaOAuthConfig('alpaca-paper', 'paper'), { providerId: 'github-repo', authorizationUrl: 'https://github.com/login/oauth/authorize', diff --git a/apps/tradinggoose/lib/auth/auth-error-copy.test.ts b/apps/tradinggoose/lib/auth/auth-error-copy.test.ts index 9844c7cb6..477d35dd1 100644 --- a/apps/tradinggoose/lib/auth/auth-error-copy.test.ts +++ b/apps/tradinggoose/lib/auth/auth-error-copy.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest' -import { getAuthErrorContent, normalizeAuthErrorCode } from '@/lib/auth/auth-error-copy' import { - REGISTRATION_DISABLED_MESSAGE, - REGISTRATION_WAITLIST_MESSAGE, -} from '@/lib/registration/shared' + getAuthErrorActionLabel, + getAuthErrorContent, + normalizeAuthErrorCode, +} from '@/lib/auth/auth-error-copy' +import { getPublicCopy } from '@/i18n/public-copy' describe('normalizeAuthErrorCode', () => { it('normalizes lowercase query values into uppercase snake case', () => { @@ -17,7 +18,7 @@ describe('normalizeAuthErrorCode', () => { describe('getAuthErrorContent', () => { it('returns the signup recovery copy for account creation failures', () => { - const { code, content } = getAuthErrorContent('unable_to_create_user') + const { code, content } = getAuthErrorContent(getPublicCopy('en'), 'unable_to_create_user') expect(code).toBe('UNABLE_TO_CREATE_USER') expect(content.title).toBe("We couldn't create your account") @@ -26,7 +27,7 @@ describe('getAuthErrorContent', () => { }) it('falls back to the default auth error copy for unknown codes', () => { - const { code, content } = getAuthErrorContent('totally_unknown_error') + const { code, content } = getAuthErrorContent(getPublicCopy('en'), 'totally_unknown_error') expect(code).toBe('TOTALLY_UNKNOWN_ERROR') expect(content.title).toBe('Something went wrong') @@ -35,21 +36,51 @@ describe('getAuthErrorContent', () => { it('maps the normalized waitlist registration code to waitlist recovery copy', () => { const { code, content } = getAuthErrorContent( + getPublicCopy('en'), 'registration_is_limited_to_approved_waitlist_emails' ) expect(code).toBe('REGISTRATION_IS_LIMITED_TO_APPROVED_WAITLIST_EMAILS') expect(content.title).toBe('Registration is limited') - expect(content.description).toBe(REGISTRATION_WAITLIST_MESSAGE) + expect(content.description).toBe('Registration is limited to approved waitlist emails.') expect(content.primaryAction.href).toBe('/waitlist') }) it('maps the normalized disabled registration code to the disabled recovery copy', () => { - const { code, content } = getAuthErrorContent('registration_is_currently_disabled') + const { code, content } = getAuthErrorContent( + getPublicCopy('en'), + 'registration_is_currently_disabled' + ) expect(code).toBe('REGISTRATION_IS_CURRENTLY_DISABLED') expect(content.title).toBe('Registration is currently disabled') - expect(content.description).toBe(REGISTRATION_DISABLED_MESSAGE) + expect(content.description).toBe('Registration is currently disabled.') expect(content.primaryAction.href).toBe('/login?reauth=1') }) + + it('maps auth error action labels to localized copy', () => { + const copy = getPublicCopy('es') + + expect(getAuthErrorActionLabel(copy, '/verify', 'Verify email')).toBe( + copy.auth.common.verifyEmail + ) + expect(getAuthErrorActionLabel(copy, '/waitlist', 'Join waitlist')).toBe( + copy.registration.waitlist.auth + ) + expect(getAuthErrorActionLabel(copy, '/login?reauth=1', 'Back to login')).toBe( + copy.auth.common.backToLogin + ) + }) + + it('returns localized auth error content for non-English locales', () => { + const esCopy = getPublicCopy('es') + const zhCopy = getPublicCopy('zh-CN') + + expect(getAuthErrorContent(esCopy, 'unable_to_create_user').content.title).toBe( + 'No pudimos crear tu cuenta' + ) + expect(getAuthErrorContent(zhCopy, 'registration_is_currently_disabled').content.title).toBe( + '注册已暂时禁用' + ) + }) }) diff --git a/apps/tradinggoose/lib/auth/auth-error-copy.ts b/apps/tradinggoose/lib/auth/auth-error-copy.ts index cce75f915..045a7e6bf 100644 --- a/apps/tradinggoose/lib/auth/auth-error-copy.ts +++ b/apps/tradinggoose/lib/auth/auth-error-copy.ts @@ -2,6 +2,7 @@ import { REGISTRATION_DISABLED_MESSAGE, REGISTRATION_WAITLIST_MESSAGE, } from '@/lib/registration/shared' +import type { PublicCopy } from '@/i18n/public-copy' export interface AuthErrorAction { href: string @@ -15,6 +16,8 @@ export interface AuthErrorContent { secondaryAction: AuthErrorAction } +type AuthErrorGroupKey = keyof PublicCopy['auth']['error']['groups'] + const LOGIN_ACTION: AuthErrorAction = { href: '/login?reauth=1', label: 'Back to login', @@ -40,159 +43,124 @@ const WAITLIST_ACTION: AuthErrorAction = { label: 'Join waitlist', } -const DEFAULT_AUTH_ERROR_CONTENT: AuthErrorContent = { - title: 'Something went wrong', - description: 'We could not complete that authentication request. Please try signing in again.', +const DEFAULT_AUTH_ERROR_ACTIONS = { primaryAction: LOGIN_ACTION, secondaryAction: HOME_ACTION, } -const REGISTRATION_WAITLIST_ERROR_CODE = normalizeAuthErrorCode(REGISTRATION_WAITLIST_MESSAGE) -const REGISTRATION_DISABLED_ERROR_CODE = normalizeAuthErrorCode(REGISTRATION_DISABLED_MESSAGE) - -const AUTH_ERROR_CONTENT_BY_CODE: Record<string, AuthErrorContent> = { - UNABLE_TO_CREATE_USER: { - title: "We couldn't create your account", - description: - 'Your sign-up request did not complete. Try again from the sign-up form, or log in if this email is already registered.', - primaryAction: SIGNUP_ACTION, - secondaryAction: LOGIN_ACTION, - }, - FAILED_TO_CREATE_USER: { - title: "We couldn't create your account", - description: - 'Your sign-up request did not complete. Try again from the sign-up form, or log in if this email is already registered.', +const AUTH_ERROR_ACTIONS_BY_GROUP: Record< + AuthErrorGroupKey, + { + primaryAction: AuthErrorAction + secondaryAction: AuthErrorAction + } +> = { + accountCreation: { primaryAction: SIGNUP_ACTION, secondaryAction: LOGIN_ACTION, }, - USER_ALREADY_EXISTS: { - title: 'Account already exists', - description: - 'An account with this email is already registered. Sign in instead of creating a new account.', - primaryAction: LOGIN_ACTION, - secondaryAction: SIGNUP_ACTION, - }, - USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: { - title: 'Account already exists', - description: - 'An account with this email is already registered. Sign in instead of creating a new account.', + accountExists: { primaryAction: LOGIN_ACTION, secondaryAction: SIGNUP_ACTION, }, - EMAIL_NOT_VERIFIED: { - title: 'Verify your email to continue', - description: 'Your account exists, but email verification still needs to be completed.', + emailVerification: { primaryAction: VERIFY_ACTION, secondaryAction: LOGIN_ACTION, }, - INVALID_CALLBACK_URL: { - title: 'This sign-in link is invalid', - description: 'The authentication callback was not valid. Start the sign-in flow again.', + invalidCallback: { primaryAction: LOGIN_ACTION, secondaryAction: HOME_ACTION, }, - INVALID_REDIRECT_URL: { - title: 'This sign-in link is invalid', - description: 'The authentication callback was not valid. Start the sign-in flow again.', + invalidToken: { primaryAction: LOGIN_ACTION, secondaryAction: HOME_ACTION, }, - INVALID_ERROR_CALLBACK_URL: { - title: 'This sign-in link is invalid', - description: 'The authentication callback was not valid. Start the sign-in flow again.', + expiredToken: { primaryAction: LOGIN_ACTION, secondaryAction: HOME_ACTION, }, - INVALID_NEW_USER_CALLBACK_URL: { - title: 'This sign-in link is invalid', - description: 'The authentication callback was not valid. Start the sign-in flow again.', + sessionCreation: { primaryAction: LOGIN_ACTION, secondaryAction: HOME_ACTION, }, - CALLBACK_URL_REQUIRED: { - title: 'This sign-in link is invalid', - description: 'The authentication callback was not valid. Start the sign-in flow again.', + sessionRestore: { primaryAction: LOGIN_ACTION, secondaryAction: HOME_ACTION, }, - INVALID_TOKEN: { - title: 'This authentication link is invalid', - description: 'The link or token could not be verified. Start the authentication flow again.', + sessionExpired: { primaryAction: LOGIN_ACTION, secondaryAction: HOME_ACTION, }, - TOKEN_EXPIRED: { - title: 'This authentication link has expired', - description: 'The link or token has expired. Start the authentication flow again.', + userInfo: { primaryAction: LOGIN_ACTION, secondaryAction: HOME_ACTION, }, - FAILED_TO_CREATE_SESSION: { - title: "We couldn't start your session", - description: - 'Authentication succeeded, but the session could not be created. Try logging in again.', + providerUnavailable: { primaryAction: LOGIN_ACTION, secondaryAction: HOME_ACTION, }, - FAILED_TO_GET_SESSION: { - title: "We couldn't restore your session", - description: 'Your session could not be loaded. Try logging in again.', + linkedAccount: { primaryAction: LOGIN_ACTION, secondaryAction: HOME_ACTION, }, - SESSION_EXPIRED: { - title: 'Your session has expired', - description: 'Sign in again to continue.', - primaryAction: LOGIN_ACTION, - secondaryAction: HOME_ACTION, - }, - FAILED_TO_GET_USER_INFO: { - title: "We couldn't complete your sign-in", - description: 'We were unable to read your identity from the provider. Try signing in again.', - primaryAction: LOGIN_ACTION, - secondaryAction: HOME_ACTION, - }, - USER_EMAIL_NOT_FOUND: { - title: "We couldn't complete your sign-in", - description: - 'The provider did not return an email address for this account. Try another sign-in method.', - primaryAction: LOGIN_ACTION, - secondaryAction: HOME_ACTION, - }, - PROVIDER_NOT_FOUND: { - title: 'This sign-in provider is unavailable', - description: 'The requested sign-in provider is not configured right now.', - primaryAction: LOGIN_ACTION, - secondaryAction: HOME_ACTION, - }, - SOCIAL_ACCOUNT_ALREADY_LINKED: { - title: 'This provider is already linked', - description: - 'That sign-in provider is already connected to another account. Use a different method to continue.', - primaryAction: LOGIN_ACTION, - secondaryAction: HOME_ACTION, - }, - LINKED_ACCOUNT_ALREADY_EXISTS: { - title: 'This provider is already linked', - description: - 'That sign-in provider is already connected to another account. Use a different method to continue.', - primaryAction: LOGIN_ACTION, - secondaryAction: HOME_ACTION, - }, - [REGISTRATION_WAITLIST_ERROR_CODE!]: { - title: 'Registration is limited', - description: REGISTRATION_WAITLIST_MESSAGE, + waitlistLimited: { primaryAction: WAITLIST_ACTION, secondaryAction: LOGIN_ACTION, }, - [REGISTRATION_DISABLED_ERROR_CODE!]: { - title: 'Registration is currently disabled', - description: REGISTRATION_DISABLED_MESSAGE, + registrationDisabled: { primaryAction: LOGIN_ACTION, secondaryAction: HOME_ACTION, }, } +export function getAuthErrorActionLabel( + copy: PublicCopy, + href: string, + fallbackLabel: string +): string { + if (href.startsWith('/login')) return copy.auth.common.backToLogin + if (href === '/signup') return copy.auth.common.backToSignup + if (href === '/') return copy.auth.common.returnHome + if (href === '/verify') return copy.auth.common.verifyEmail + if (href === '/waitlist') return copy.registration.waitlist.auth + + return fallbackLabel +} + +const REGISTRATION_WAITLIST_ERROR_CODE = normalizeAuthErrorCode(REGISTRATION_WAITLIST_MESSAGE) +const REGISTRATION_DISABLED_ERROR_CODE = normalizeAuthErrorCode(REGISTRATION_DISABLED_MESSAGE) + +const AUTH_ERROR_GROUP_BY_CODE: Record<string, AuthErrorGroupKey> = { + UNABLE_TO_CREATE_USER: 'accountCreation', + FAILED_TO_CREATE_USER: 'accountCreation', + USER_ALREADY_EXISTS: 'accountExists', + USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: 'accountExists', + EMAIL_NOT_VERIFIED: 'emailVerification', + INVALID_CALLBACK_URL: 'invalidCallback', + INVALID_REDIRECT_URL: 'invalidCallback', + INVALID_ERROR_CALLBACK_URL: 'invalidCallback', + INVALID_NEW_USER_CALLBACK_URL: 'invalidCallback', + CALLBACK_URL_REQUIRED: 'invalidCallback', + INVALID_TOKEN: 'invalidToken', + TOKEN_EXPIRED: 'expiredToken', + FAILED_TO_CREATE_SESSION: 'sessionCreation', + FAILED_TO_GET_SESSION: 'sessionRestore', + SESSION_EXPIRED: 'sessionExpired', + FAILED_TO_GET_USER_INFO: 'userInfo', + USER_EMAIL_NOT_FOUND: 'userInfo', + PROVIDER_NOT_FOUND: 'providerUnavailable', + SOCIAL_ACCOUNT_ALREADY_LINKED: 'linkedAccount', + LINKED_ACCOUNT_ALREADY_EXISTS: 'linkedAccount', +} + +if (REGISTRATION_WAITLIST_ERROR_CODE) { + AUTH_ERROR_GROUP_BY_CODE[REGISTRATION_WAITLIST_ERROR_CODE] = 'waitlistLimited' +} + +if (REGISTRATION_DISABLED_ERROR_CODE) { + AUTH_ERROR_GROUP_BY_CODE[REGISTRATION_DISABLED_ERROR_CODE] = 'registrationDisabled' +} + export function normalizeAuthErrorCode(error: string | null | undefined) { if (!error) { return null @@ -208,25 +176,24 @@ export function normalizeAuthErrorCode(error: string | null | undefined) { } export function getAuthErrorContent( + copy: PublicCopy, error: string | null | undefined, errorDescription?: string | null ) { const code = normalizeAuthErrorCode(error) const normalizedDescription = errorDescription?.trim() || null - const contentForCode = code ? AUTH_ERROR_CONTENT_BY_CODE[code] : null + const errorCopy = copy.auth.error + const groupKey = code ? AUTH_ERROR_GROUP_BY_CODE[code] : null + const content = groupKey ? errorCopy.groups[groupKey] : errorCopy.default + const actions = groupKey ? AUTH_ERROR_ACTIONS_BY_GROUP[groupKey] : DEFAULT_AUTH_ERROR_ACTIONS return { code, - content: contentForCode - ? { - ...contentForCode, - description: normalizedDescription || contentForCode.description, - } - : normalizedDescription - ? { - ...DEFAULT_AUTH_ERROR_CONTENT, - description: normalizedDescription, - } - : DEFAULT_AUTH_ERROR_CONTENT, + content: { + title: content.title, + description: normalizedDescription || content.description, + primaryAction: actions.primaryAction, + secondaryAction: actions.secondaryAction, + }, } } diff --git a/apps/tradinggoose/lib/auth/auth-error-handler.ts b/apps/tradinggoose/lib/auth/auth-error-handler.ts index 75577a1fe..27944b09a 100644 --- a/apps/tradinggoose/lib/auth/auth-error-handler.ts +++ b/apps/tradinggoose/lib/auth/auth-error-handler.ts @@ -1,6 +1,7 @@ 'use client' import { createLogger } from '@/lib/logs/console/logger' +import { localizeHref, stripLocaleFromPathname } from '@/i18n/utils' const logger = createLogger('AuthErrorHandler') let isHandlingAuthError = false @@ -33,7 +34,7 @@ function shouldRateLimitRecovery(reason?: string) { if (typeof window === 'undefined') return false // Avoid infinite reload loops on the login page by rate limiting recovery attempts - const isOnLoginPage = window.location.pathname === '/login' + const isOnLoginPage = stripLocaleFromPathname(window.location.pathname).pathname === '/login' if (!isOnLoginPage) return false const now = Date.now() @@ -73,7 +74,7 @@ export async function handleAuthError(reason?: string) { deleteBrowserAuthCookies() await safeServerSignOut() - if (window.location.pathname === '/login') { + if (stripLocaleFromPathname(window.location.pathname).pathname === '/login') { logger.warn('Cleared stale auth state on login page', { reason }) isHandlingAuthError = false return @@ -81,7 +82,10 @@ export async function handleAuthError(reason?: string) { const callbackUrl = `${window.location.pathname}${window.location.search}` logger.warn('Handling authentication error', { reason, callbackUrl }) - window.location.replace(`/login?reauth=1&callbackUrl=${encodeURIComponent(callbackUrl)}`) + const locale = stripLocaleFromPathname(window.location.pathname).locale + window.location.replace( + localizeHref(locale, `/login?reauth=1&callbackUrl=${encodeURIComponent(callbackUrl)}`) + ) } export function isAuthErrorStatus(status?: number | null): boolean { diff --git a/apps/tradinggoose/lib/copilot/access-policy.ts b/apps/tradinggoose/lib/copilot/access-policy.ts index 396b7b4fd..57a37a379 100644 --- a/apps/tradinggoose/lib/copilot/access-policy.ts +++ b/apps/tradinggoose/lib/copilot/access-policy.ts @@ -1,17 +1,21 @@ export type CopilotAccessLevel = 'limited' | 'full' +export function shouldBypassCopilotApproval(accessLevel: CopilotAccessLevel): boolean { + return accessLevel === 'full' +} + +export function shouldRequireCopilotApproval(accessLevel: CopilotAccessLevel): boolean { + return !shouldBypassCopilotApproval(accessLevel) +} + export function shouldAutoExecuteCopilotTool( accessLevel: CopilotAccessLevel, hasInterrupt: boolean, entersReviewState = false ): boolean { - return accessLevel === 'full' || entersReviewState || !hasInterrupt + return shouldBypassCopilotApproval(accessLevel) || entersReviewState || !hasInterrupt } export function shouldAutoExecuteIntegrationTool(accessLevel: CopilotAccessLevel): boolean { - return accessLevel === 'full' -} - -export function shouldAutoApplyWorkflowEdits(accessLevel: CopilotAccessLevel): boolean { - return accessLevel === 'full' + return shouldBypassCopilotApproval(accessLevel) } diff --git a/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts b/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts index 0157f8862..65ace60c7 100644 --- a/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts +++ b/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts @@ -27,6 +27,14 @@ describe('chat replay safety', () => { expect( isAcceptedLiveMutationToolCall({ id: 'tool-3', + name: 'edit_workflow_block', + state: 'success', + }) + ).toBe(true) + + expect( + isAcceptedLiveMutationToolCall({ + id: 'tool-3b', name: 'set_global_workflow_variables', state: 'success', }) @@ -40,6 +48,16 @@ describe('chat replay safety', () => { }) ).toBe(true) + for (const name of ['edit_skill', 'edit_custom_tool', 'edit_indicator', 'edit_mcp_server']) { + expect( + isAcceptedLiveMutationToolCall({ + id: `tool-${name}`, + name, + state: 'success', + }) + ).toBe(true) + } + expect( isAcceptedLiveMutationToolCall({ id: 'tool-5', @@ -56,6 +74,22 @@ describe('chat replay safety', () => { }) ).toBe(true) + expect( + isAcceptedLiveMutationToolCall({ + id: 'tool-5c', + name: 'set_environment_variables', + state: 'success', + }) + ).toBe(true) + + expect( + isAcceptedLiveMutationToolCall({ + id: 'tool-5d', + name: 'deploy_workflow', + state: 'success', + }) + ).toBe(true) + expect( isAcceptedLiveMutationToolCall({ id: 'tool-6', diff --git a/apps/tradinggoose/lib/copilot/chat-replay-safety.ts b/apps/tradinggoose/lib/copilot/chat-replay-safety.ts index 43e37fd35..178c4d6c8 100644 --- a/apps/tradinggoose/lib/copilot/chat-replay-safety.ts +++ b/apps/tradinggoose/lib/copilot/chat-replay-safety.ts @@ -1,3 +1,5 @@ +import { TOOL_PROMPT_METADATA } from '@/lib/copilot/tool-prompt-metadata' + interface ReplaySafetyToolCallLike { name?: string | null state?: string | null @@ -19,28 +21,15 @@ export interface ReplaySafetyMessageLike { export const EDIT_REPLAY_BLOCKED_MESSAGE = 'Cannot edit a prompt that precedes accepted live changes.' -const ACCEPTED_WORKFLOW_MUTATION_STATES = new Set(['success', 'accepted']) -const ALWAYS_UNSAFE_LIVE_MUTATION_TOOL_NAMES = new Set([ - 'create_workflow', - 'edit_workflow', - 'rename_workflow', - 'set_global_workflow_variables', - 'edit_monitor', - 'create_skill', - 'edit_skill', - 'rename_skill', - 'create_custom_tool', - 'edit_custom_tool', - 'rename_custom_tool', - 'create_indicator', - 'edit_indicator', - 'rename_indicator', - 'create_mcp_server', - 'edit_mcp_server', - 'rename_mcp_server', -]) - -function asWorkflowToolCall(value: unknown): ReplaySafetyToolCallLike | null { +const ACCEPTED_LIVE_MUTATION_STATES = new Set(['success', 'accepted']) +const LIVE_MUTATION_KINDS = new Set(['create', 'edit', 'rename', 'deploy']) +const LIVE_MUTATION_TOOL_NAMES = new Set( + Object.entries(TOOL_PROMPT_METADATA) + .filter(([, metadata]) => metadata.kind && LIVE_MUTATION_KINDS.has(metadata.kind)) + .map(([toolName]) => toolName) +) + +function asToolCall(value: unknown): ReplaySafetyToolCallLike | null { if (!value || typeof value !== 'object') { return null } @@ -57,19 +46,19 @@ function asToolCallBlock(value: unknown): ReplaySafetyBlockLike | null { } export function isAcceptedLiveMutationToolCall(toolCall: unknown): boolean { - const candidate = asWorkflowToolCall(toolCall) + const candidate = asToolCall(toolCall) if (!candidate) { return false } if ( typeof candidate.state !== 'string' || - !ACCEPTED_WORKFLOW_MUTATION_STATES.has(candidate.state) + !ACCEPTED_LIVE_MUTATION_STATES.has(candidate.state) ) { return false } - if (candidate.name && ALWAYS_UNSAFE_LIVE_MUTATION_TOOL_NAMES.has(candidate.name)) { + if (candidate.name && LIVE_MUTATION_TOOL_NAMES.has(candidate.name)) { return true } diff --git a/apps/tradinggoose/lib/copilot/inline-tool-call.test.tsx b/apps/tradinggoose/lib/copilot/inline-tool-call.test.tsx index e31279664..d09172470 100644 --- a/apps/tradinggoose/lib/copilot/inline-tool-call.test.tsx +++ b/apps/tradinggoose/lib/copilot/inline-tool-call.test.tsx @@ -236,6 +236,56 @@ describe('InlineToolCall', () => { expect(container.querySelector('[data-testid="workflow-preview"]')).toBeNull() }) + it('renders block edit approval details for staged edit_workflow_block results', async () => { + await act(async () => { + root.render( + <InlineToolCall + toolCall={{ + id: 'tool-block-review', + name: 'edit_workflow_block', + state: ClientToolCallState.review, + params: { + workflowId: 'wf-1', + blockId: 'fn1', + blockType: 'function', + name: 'Compute Market Indicators', + enabled: false, + subBlocks: { + code: 'return { rsi: 50 }', + }, + }, + result: { + workflowState: { + blocks: { + fn1: { + id: 'fn1', + type: 'function', + name: 'Compute Market Indicators', + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + }, + }} + /> + ) + }) + + expect(container.textContent).toContain('Proposed Workflow Block Changes') + expect(container.textContent).toContain('fn1') + expect(container.textContent).toContain('function') + expect(container.textContent).toContain('Compute Market Indicators') + expect(container.textContent).toContain('Enabled') + expect(container.textContent).toContain('false') + expect(container.textContent).toContain('subBlocks.code') + expect(container.textContent).toContain('return { rsi: 50 }') + expect(container.querySelector('[data-testid="workflow-preview"]')?.textContent).toContain( + 'fn1' + ) + }) + it('renders entity diffs in the copilot widget for pending entity edits', async () => { mockEntitySession.doc = { id: 'entity-doc' } mockEntitySession.descriptor = { diff --git a/apps/tradinggoose/lib/copilot/inline-tool-call.tsx b/apps/tradinggoose/lib/copilot/inline-tool-call.tsx index 2e74f1610..dd1f13c8a 100644 --- a/apps/tradinggoose/lib/copilot/inline-tool-call.tsx +++ b/apps/tradinggoose/lib/copilot/inline-tool-call.tsx @@ -5,7 +5,7 @@ import { Loader2 } from 'lucide-react' import useDrivePicker from 'react-google-drive-picker' import { GoogleDriveIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' -import type { CopilotAccessLevel } from '@/lib/copilot/access-policy' +import { shouldRequireCopilotApproval, type CopilotAccessLevel } from '@/lib/copilot/access-policy' import { buildEntityReviewDiffLines, buildEntityReviewDiffPayload, @@ -39,7 +39,6 @@ interface InlineToolCallProps { toolCall?: CopilotToolCall toolCallId?: string onStateChange?: (state: any) => void - context?: Record<string, any> } const ACTION_VERBS = [ @@ -198,15 +197,11 @@ function shouldShowRunSkipButtons( return true } - if (hasInterrupt && toolCall.state === 'pending' && options.accessLevel === 'limited') { - return true - } - - if (options.isIntegration && toolCall.state === 'pending' && options.accessLevel === 'limited') { - return true - } - - return false + return ( + toolCall.state === 'pending' && + shouldRequireCopilotApproval(options.accessLevel) && + (hasInterrupt || options.isIntegration) + ) } function getStateVerb(state: string): string { @@ -245,7 +240,7 @@ function getEntityDiffLineClasses(type: 'context' | 'removed' | 'added'): string } function readWorkflowReviewPayload(toolCall: CopilotToolCall): WorkflowReviewPayload | null { - if (toolCall.name !== 'edit_workflow') { + if (toolCall.name !== 'edit_workflow' && toolCall.name !== 'edit_workflow_block') { return null } @@ -496,7 +491,6 @@ export function InlineToolCall({ toolCall: toolCallProp, toolCallId, onStateChange, - context, }: InlineToolCallProps) { const [, forceUpdate] = useState({}) const liveToolCall = useCopilotStore((s) => @@ -545,6 +539,32 @@ export function InlineToolCall({ const params = (toolCall as any).parameters || (toolCall as any).input || toolCall.params || {} const workflowReviewPayload = readWorkflowReviewPayload(toolCall) const showWorkflowReview = workflowReviewPayload && toolCall.state === ClientToolCallState.review + const workflowBlockEditRows = + toolCall.name === 'edit_workflow_block' + ? [ + ...(typeof params.name === 'string' && params.name.trim() + ? [{ key: 'name', label: 'Name', value: params.name.trim() }] + : []), + ...(typeof params.enabled === 'boolean' + ? [{ key: 'enabled', label: 'Enabled', value: String(params.enabled) }] + : []), + ...(params.subBlocks && + typeof params.subBlocks === 'object' && + !Array.isArray(params.subBlocks) + ? Object.entries(params.subBlocks) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => ({ + key: `subBlocks.${key}`, + label: `subBlocks.${key}`, + value: typeof value === 'string' ? value : (JSON.stringify(value, null, 2) ?? ''), + })) + : []), + ] + : [] + const showWorkflowBlockEditRequest = + workflowBlockEditRows.length > 0 && + (toolCall.state === ClientToolCallState.pending || + toolCall.state === ClientToolCallState.review) const entityReviewDiffPayload = entitySession.doc && entitySession.descriptor ? buildEntityReviewDiffPayload( @@ -811,6 +831,35 @@ export function InlineToolCall({ </div> </div> ) : null} + {showWorkflowBlockEditRequest ? ( + <div className='pr-1 pl-5'> + <div className='flex flex-col gap-2 rounded-md border border-orange-200/70 bg-card/60 p-3 dark:border-orange-900/50'> + <div className='flex flex-wrap items-center gap-2'> + <div className='font-medium text-[11px] text-muted-foreground uppercase tracking-wide'> + Proposed Workflow Block Changes + </div> + <span className='rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground'> + {String(params.blockId || '')} + </span> + {typeof params.blockType === 'string' && params.blockType.trim() ? ( + <span className='rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground'> + {params.blockType.trim()} + </span> + ) : null} + </div> + <div className='divide-y divide-muted/60 overflow-hidden rounded-md border border-border/60 bg-background/70'> + {workflowBlockEditRows.map((row) => ( + <div key={row.key} className='grid gap-1 px-2 py-1.5'> + <div className='font-medium text-[11px] text-muted-foreground'>{row.label}</div> + <div className='max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground'> + {row.value || ' '} + </div> + </div> + ))} + </div> + </div> + </div> + ) : null} {showWorkflowReview && workflowReviewPayload ? ( <div className='pr-1 pl-5'> <div className='flex flex-col gap-3 rounded-md border border-border/60 bg-card/60 p-3'> diff --git a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts index 242b7396a..7d854e1f0 100644 --- a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts +++ b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts @@ -287,11 +287,19 @@ describe('copilot runtime tool manifest', () => { expect(manifest.tools.find((tool) => tool.name === 'edit_monitor')?.description).not.toContain( 'confirmation' ) - expect(toolNames).toContain('edit_workflow') - expect(toolNames).toContain('edit_workflow_block') - expect(toolNames).toContain('create_workflow') - expect(toolNames).toContain('get_indicator_catalog') - expect(toolNames).toContain('get_indicator_metadata') - expect(toolNames).toContain('rename_skill') + expect(toolNames).toEqual( + expect.arrayContaining([ + 'edit_workflow', + 'edit_workflow_block', + 'edit_skill', + 'edit_custom_tool', + 'edit_indicator', + 'edit_mcp_server', + 'create_workflow', + 'get_indicator_catalog', + 'get_indicator_metadata', + 'rename_skill', + ]) + ) }) }) diff --git a/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts b/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts index 2a116054d..a145cbc52 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts @@ -373,8 +373,4 @@ export class BaseClientTool { getState(): ClientToolCallState { return this.state } - - hasInterrupt(): boolean { - return !!this.metadata.interrupt - } } diff --git a/apps/tradinggoose/lib/copilot/tools/client/other/oauth-request-access.ts b/apps/tradinggoose/lib/copilot/tools/client/other/oauth-request-access.ts index bbd352e5c..e67c6e4dd 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/other/oauth-request-access.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/other/oauth-request-access.ts @@ -7,6 +7,7 @@ import { import { createLogger } from '@/lib/logs/console/logger' import { startOAuthConnectFlow } from '@/lib/oauth/connect' import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth/oauth' +import { localizeHref, stripLocaleFromPathname } from '@/i18n/utils' const logger = createLogger('OAuthRequestAccessClientTool') @@ -124,10 +125,11 @@ export class OAuthRequestAccessClientTool extends BaseClientTool { this.setState(ClientToolCallState.executing) if (typeof window !== 'undefined') { + const { locale } = stripLocaleFromPathname(window.location.pathname) const pathMatch = window.location.pathname.match(/\/workspace\/([^/]+)/) const workspaceId = pathMatch?.[1] const callbackURL = workspaceId - ? `${window.location.origin}/workspace/${workspaceId}/integrations` + ? `${window.location.origin}${localizeHref(locale, `/workspace/${workspaceId}/integrations`)}` : window.location.href try { diff --git a/apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts b/apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts new file mode 100644 index 000000000..2fc5425d7 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts @@ -0,0 +1,255 @@ +import { + Blocks, + BookOpen, + BookOpenText, + FileSearch, + FileText, + FolderOpen, + Globe, + Globe2, + Key, + KeyRound, + ListFilter, + Loader2, + MinusCircle, + Settings2, + TerminalSquare, + X, + XCircle, +} from 'lucide-react' +import { + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' + +export const SERVER_TOOL_METADATA = { + get_workflow_console: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Fetching workflow console', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Fetching workflow console', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Workflow console fetched', icon: TerminalSquare }, + [ClientToolCallState.error]: { text: 'Failed to read workflow console', icon: XCircle }, + [ClientToolCallState.rejected]: { + text: 'Skipped reading workflow console', + icon: MinusCircle, + }, + [ClientToolCallState.aborted]: { + text: 'Aborted reading workflow console', + icon: MinusCircle, + }, + [ClientToolCallState.pending]: { text: 'Fetching workflow console', icon: Loader2 }, + }, + }, + get_blocks_and_tools: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Exploring workflow blocks', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Exploring workflow blocks', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Exploring workflow blocks', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Explored workflow blocks', icon: Blocks }, + [ClientToolCallState.error]: { text: 'Failed to explore workflow blocks', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted exploring workflow blocks', icon: MinusCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped exploring workflow blocks', icon: MinusCircle }, + }, + }, + get_blocks_metadata: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Inspecting block shapes', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Inspecting block shapes', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Inspecting block shapes', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Inspected block shapes', icon: ListFilter }, + [ClientToolCallState.error]: { text: 'Failed to inspect block shapes', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted inspecting block shapes', icon: XCircle }, + [ClientToolCallState.rejected]: { + text: 'Skipped inspecting block shapes', + icon: MinusCircle, + }, + }, + }, + get_indicator_catalog: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Exploring indicator catalog', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Exploring indicator catalog', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Exploring indicator catalog', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Explored indicator catalog', icon: BookOpenText }, + [ClientToolCallState.error]: { text: 'Failed to explore indicator catalog', icon: XCircle }, + [ClientToolCallState.aborted]: { + text: 'Aborted exploring indicator catalog', + icon: MinusCircle, + }, + [ClientToolCallState.rejected]: { + text: 'Skipped exploring indicator catalog', + icon: MinusCircle, + }, + }, + }, + get_indicator_metadata: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Inspecting indicator metadata', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Inspecting indicator metadata', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Inspecting indicator metadata', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Inspected indicator metadata', icon: FileSearch }, + [ClientToolCallState.error]: { text: 'Failed to inspect indicator metadata', icon: XCircle }, + [ClientToolCallState.aborted]: { + text: 'Aborted inspecting indicator metadata', + icon: MinusCircle, + }, + [ClientToolCallState.rejected]: { + text: 'Skipped inspecting indicator metadata', + icon: MinusCircle, + }, + }, + }, + get_trigger_blocks: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Finding trigger blocks', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Finding trigger blocks', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Finding trigger blocks', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Found trigger blocks', icon: ListFilter }, + [ClientToolCallState.error]: { text: 'Failed to find trigger blocks', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted finding trigger blocks', icon: MinusCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped finding trigger blocks', icon: MinusCircle }, + }, + }, + search_online: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Searching online', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Searching online', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Searching online', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Online search complete', icon: Globe }, + [ClientToolCallState.error]: { text: 'Failed to search online', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle }, + }, + }, + search_documentation: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Searching documentation', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Searching documentation', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Searching documentation', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Documentation search complete', icon: BookOpen }, + [ClientToolCallState.error]: { text: 'Failed to search docs', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted documentation search', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped documentation search', icon: MinusCircle }, + }, + }, + get_environment_variables: { + displayNames: { + [ClientToolCallState.generating]: { + text: 'Reading environment variables', + icon: Loader2, + }, + [ClientToolCallState.pending]: { text: 'Reading environment variables', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Reading environment variables', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Read environment variables', icon: KeyRound }, + [ClientToolCallState.error]: { text: 'Failed to read environment variables', icon: XCircle }, + [ClientToolCallState.aborted]: { + text: 'Aborted reading environment variables', + icon: MinusCircle, + }, + [ClientToolCallState.rejected]: { + text: 'Skipped reading environment variables', + icon: MinusCircle, + }, + }, + }, + set_environment_variables: { + displayNames: { + [ClientToolCallState.generating]: { + text: 'Preparing to set environment variables', + icon: Loader2, + }, + [ClientToolCallState.pending]: { text: 'Set environment variables?', icon: Settings2 }, + [ClientToolCallState.executing]: { text: 'Setting environment variables', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Set environment variables', icon: Settings2 }, + [ClientToolCallState.error]: { text: 'Failed to set environment variables', icon: X }, + [ClientToolCallState.aborted]: { + text: 'Aborted setting environment variables', + icon: XCircle, + }, + [ClientToolCallState.rejected]: { + text: 'Skipped setting environment variables', + icon: XCircle, + }, + }, + interrupt: { + accept: { text: 'Apply', icon: Settings2 }, + reject: { text: 'Skip', icon: XCircle }, + }, + }, + get_credentials: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Fetching connected integrations', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Fetching connected integrations', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Fetching connected integrations', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Fetched connected integrations', icon: Key }, + [ClientToolCallState.error]: { + text: 'Failed to fetch connected integrations', + icon: XCircle, + }, + [ClientToolCallState.aborted]: { + text: 'Aborted fetching connected integrations', + icon: MinusCircle, + }, + [ClientToolCallState.rejected]: { + text: 'Skipped fetching connected integrations', + icon: MinusCircle, + }, + }, + }, + list_gdrive_files: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Listing GDrive files', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Listing GDrive files', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Listing GDrive files', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Listed GDrive files', icon: FolderOpen }, + [ClientToolCallState.error]: { text: 'Failed to list GDrive files', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped listing GDrive files', icon: MinusCircle }, + }, + }, + read_gdrive_file: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Reading Google Drive file', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Reading Google Drive file', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Reading Google Drive file', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Read Google Drive file', icon: FileText }, + [ClientToolCallState.error]: { text: 'Failed to read Google Drive file', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted reading Google Drive file', icon: XCircle }, + [ClientToolCallState.rejected]: { + text: 'Skipped reading Google Drive file', + icon: MinusCircle, + }, + }, + }, + get_oauth_credentials: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Fetching OAuth credentials', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Fetching OAuth credentials', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Retrieving login IDs', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Retrieved login IDs', icon: Key }, + [ClientToolCallState.error]: { text: 'Failed to retrieve login IDs', icon: XCircle }, + [ClientToolCallState.aborted]: { + text: 'Aborted fetching OAuth credentials', + icon: MinusCircle, + }, + [ClientToolCallState.rejected]: { + text: 'Skipped fetching OAuth credentials', + icon: MinusCircle, + }, + }, + }, + make_api_request: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Preparing API request', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Review API request', icon: Globe2 }, + [ClientToolCallState.executing]: { text: 'Executing API request', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'API request complete', icon: Globe2 }, + [ClientToolCallState.error]: { text: 'Failed to execute API request', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped API request', icon: MinusCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted API request', icon: XCircle }, + }, + interrupt: { + accept: { text: 'Execute', icon: Globe2 }, + reject: { text: 'Skip', icon: MinusCircle }, + }, + }, +} satisfies Record<string, BaseClientToolMetadata> diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/deploy-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/deploy-workflow.ts index ae41cd50c..cf3985ebb 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/deploy-workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/deploy-workflow.ts @@ -7,6 +7,7 @@ import { import { createLogger } from '@/lib/logs/console/logger' import { resolveWorkflowTarget } from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils' import { getInputFormatExample } from '@/lib/workflows/operations/deployment-utils' +import { localizeHref, stripLocaleFromPathname } from '@/i18n/utils' import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -91,13 +92,11 @@ export class DeployWorkflowClientTool extends BaseClientTool { // Determine action text based on deployment status let actionText = action let actionTextIng = action === 'undeploy' ? 'undeploying' : 'deploying' - let actionTextPast = action === 'undeploy' ? 'undeployed' : 'deployed' // If already deployed and action is deploy, change to redeploy if (action === 'deploy' && isAlreadyDeployed) { actionText = 'redeploy' actionTextIng = 'redeploying' - actionTextPast = 'redeployed' } const actionCapitalized = actionText.charAt(0).toUpperCase() + actionText.slice(1) @@ -167,7 +166,10 @@ export class DeployWorkflowClientTool extends BaseClientTool { * Opens the workspace API keys page. */ private openApiKeysPage(workspaceId: string): void { - window.location.assign(`/workspace/${encodeURIComponent(workspaceId)}/api-keys`) + const { locale } = stripLocaleFromPathname(window.location.pathname) + window.location.assign( + localizeHref(locale, `/workspace/${encodeURIComponent(workspaceId)}/api-keys`) + ) } /** diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.test.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.test.ts index f6f55f7a2..00c7ba1a1 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.test.ts @@ -110,7 +110,8 @@ describe('EditWorkflowBlockClientTool', () => { json: async () => ({ success: true, result: { - workflowDocument: 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', + workflowDocument: + 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', workflowState: { direction: 'TD', blocks: { @@ -165,6 +166,7 @@ describe('EditWorkflowBlockClientTool', () => { }) expect(tool.getState()).toBe(ClientToolCallState.review) + expect(tool.getInterruptDisplays()).toBeDefined() expect(mockSetWorkflowState).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts index 019580a9a..3fc00a37b 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts @@ -4,7 +4,7 @@ import { type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { shouldAutoApplyWorkflowEdits } from '@/lib/copilot/access-policy' +import { shouldBypassCopilotApproval } from '@/lib/copilot/access-policy' import { createLogger } from '@/lib/logs/console/logger' import { buildWorkflowDocumentToolResult, @@ -42,10 +42,6 @@ export class EditWorkflowClientTool extends BaseClientTool { private hasAppliedState = false private lastWorkflowId: string | null = null - private resolvePersistedStagedResult(): any | undefined { - return this.resolvePersistedResult() - } - constructor( toolCallId: string, toolName = EditWorkflowClientTool.id, @@ -83,7 +79,7 @@ export class EditWorkflowClientTool extends BaseClientTool { state: this.getState(), hasResult: this.lastResult !== undefined, }) - const stagedResult = this.lastResult ?? this.resolvePersistedStagedResult() + const stagedResult = this.lastResult ?? this.resolvePersistedResult() if (stagedResult && !this.lastResult) { this.lastResult = stagedResult } @@ -96,7 +92,9 @@ export class EditWorkflowClientTool extends BaseClientTool { const resolvedArgs = args || readStoredToolArgs<EditWorkflowArgs>(this.toolCallId) const requestedWorkflowId = resolvedArgs?.workflowId?.trim() ?? - (typeof stagedResult?.workflowId === 'string' ? stagedResult.workflowId.trim() : undefined) ?? + (typeof stagedResult?.workflowId === 'string' + ? stagedResult.workflowId.trim() + : undefined) ?? this.lastWorkflowId ?? undefined if (!requestedWorkflowId) { @@ -182,12 +180,8 @@ export class EditWorkflowClientTool extends BaseClientTool { } } - protected async getPendingUserAction(): Promise<'execute'> { - return 'execute' - } - protected async prepareReviewAccept(args?: EditWorkflowArgs): Promise<boolean> { - const stagedResult = this.lastResult ?? this.resolvePersistedStagedResult() + const stagedResult = this.lastResult ?? this.resolvePersistedResult() if (!stagedResult?.workflowState) { await this.execute(args) @@ -229,7 +223,10 @@ export class EditWorkflowClientTool extends BaseClientTool { (await getReadableWorkflowState(executionContext, workflowId)).workflowState ) } catch (e) { - logger.warn('Failed to build currentWorkflowState from readable workflow snapshot', e as any) + logger.warn( + 'Failed to build currentWorkflowState from readable workflow snapshot', + e as any + ) throw new Error('Failed to read the current workflow') } @@ -263,7 +260,7 @@ export class EditWorkflowClientTool extends BaseClientTool { }) const accessLevel = getCopilotStoreForToolCall(this.toolCallId).getState().accessLevel - if (shouldAutoApplyWorkflowEdits(accessLevel)) { + if (shouldBypassCopilotApproval(accessLevel)) { logger.info('Auto-applying workflow edits for full access session', { toolCallId: this.toolCallId, }) diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/get-workflow-from-name.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/get-workflow-from-name.ts index c95658d9f..a58200007 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/get-workflow-from-name.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/get-workflow-from-name.ts @@ -4,7 +4,6 @@ import { type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { createLogger } from '@/lib/logs/console/logger' import { serializeWorkflowToTgMermaid } from '@/lib/workflows/studio-workflow-mermaid' import { buildWorkflowSummary, @@ -13,8 +12,6 @@ import { resolveWorkflowTarget, } from './workflow-review-tool-utils' -const logger = createLogger('GetWorkflowFromNameClientTool') - interface GetWorkflowFromNameArgs { workflow_name: string } diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.ts index 4c786c93e..d09cd51d4 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.ts @@ -48,7 +48,6 @@ export class RunWorkflowClientTool extends BaseClientTool { const logger = createLogger('RunWorkflowClientTool') await this.executeWithTimeout(async () => { const params = (args ?? {}) as Partial<RunWorkflowArgs> - const executionContext = this.requireExecutionContext() logger.debug('handleAccept() called', { toolCallId: this.toolCallId, state: this.getState(), diff --git a/apps/tradinggoose/lib/discovery/link-headers.ts b/apps/tradinggoose/lib/discovery/link-headers.ts index 8744f13b2..ce5a7dcfd 100644 --- a/apps/tradinggoose/lib/discovery/link-headers.ts +++ b/apps/tradinggoose/lib/discovery/link-headers.ts @@ -1,3 +1,5 @@ +import { defaultLocale, type LocaleCode, localizeDocsUrl } from '@/i18n/utils' + export const API_CATALOG_PATH = '/.well-known/api-catalog' export const API_CATALOG_CONTENT_TYPE = 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"' @@ -14,23 +16,25 @@ type CatalogLink = { title?: string } -const HOMEPAGE_LINK_TARGETS: LinkTarget[] = [ - { - href: API_CATALOG_PATH, - rel: 'api-catalog', - type: 'application/linkset+json', - }, - { - href: 'https://docs.tradinggoose.ai', - rel: 'service-doc', - type: 'text/html', - }, - { - href: '/llms-full.txt', - rel: 'describedby', - type: 'text/plain', - }, -] +function getHomepageLinkTargets(locale: LocaleCode = defaultLocale): LinkTarget[] { + return [ + { + href: API_CATALOG_PATH, + rel: 'api-catalog', + type: 'application/linkset+json', + }, + { + href: localizeDocsUrl(locale), + rel: 'service-doc', + type: 'text/html', + }, + { + href: '/llms-full.txt', + rel: 'describedby', + type: 'text/plain', + }, + ] +} function formatLinkTarget(target: LinkTarget): string { const attributes = [`rel="${target.rel}"`] @@ -42,8 +46,8 @@ function formatLinkTarget(target: LinkTarget): string { return `<${target.href}>; ${attributes.join('; ')}` } -export function appendHomepageDiscoveryLinks(headers: Headers): void { - HOMEPAGE_LINK_TARGETS.forEach((target) => { +export function appendHomepageDiscoveryLinks(headers: Headers, locale: LocaleCode = defaultLocale): void { + getHomepageLinkTargets(locale).forEach((target) => { headers.append('Link', formatLinkTarget(target)) }) } @@ -66,7 +70,7 @@ const CATALOG_ITEM_LINKS: CatalogLink[] = [ }, ] -export function getApiCatalogDocument(origin: string) { +export function getApiCatalogDocument(origin: string, locale: LocaleCode = defaultLocale) { return { linkset: [ { @@ -78,7 +82,7 @@ export function getApiCatalogDocument(origin: string) { })), 'service-doc': [ { - href: 'https://docs.tradinggoose.ai', + href: localizeDocsUrl(locale), type: 'text/html', title: 'TradingGoose documentation', }, diff --git a/apps/tradinggoose/lib/execution/execution-concurrency-limit.test.ts b/apps/tradinggoose/lib/execution/execution-concurrency-limit.test.ts index e8169b8a1..541af4feb 100644 --- a/apps/tradinggoose/lib/execution/execution-concurrency-limit.test.ts +++ b/apps/tradinggoose/lib/execution/execution-concurrency-limit.test.ts @@ -50,16 +50,17 @@ describe('withExecutionConcurrencyLimit', () => { userId: 'user-123', })), })) - vi.doMock('@/lib/env', async (importOriginal) => { - const actual = await importOriginal<typeof import('@/lib/env')>() - return { - ...actual, - env: { - ...actual.env, - REDIS_URL: '', - }, - } - }) + vi.doMock('@/lib/env', () => ({ + getEnv: (key: string) => process.env[key], + isTruthy: (value: string | boolean | number | undefined) => + typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value), + isFalsy: (value: string | boolean | number | undefined) => + typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false, + env: { + NODE_ENV: 'test', + REDIS_URL: '', + }, + })) vi.doMock('@/lib/redis', () => ({ getRedisClient: vi.fn(() => null), })) diff --git a/apps/tradinggoose/lib/guardrails/requirements.txt b/apps/tradinggoose/lib/guardrails/requirements.txt index 135efae05..8a8724c9b 100644 --- a/apps/tradinggoose/lib/guardrails/requirements.txt +++ b/apps/tradinggoose/lib/guardrails/requirements.txt @@ -1,4 +1,3 @@ # Microsoft Presidio for PII detection -presidio-analyzer>=2.2.0 -presidio-anonymizer>=2.2.0 - +presidio-analyzer==2.2.362 +presidio-anonymizer==2.2.362 diff --git a/apps/tradinggoose/lib/indicators/browser-execution.ts b/apps/tradinggoose/lib/indicators/browser-execution.ts new file mode 100644 index 000000000..08a530e7e --- /dev/null +++ b/apps/tradinggoose/lib/indicators/browser-execution.ts @@ -0,0 +1,187 @@ +'use client' + +import { Context, Indicator, PineTS } from 'pinets' +import { normalizeContext } from '@/lib/indicators/normalize-context' +import { buildIndexMaps } from '@/lib/indicators/series-data' +import { + captureIndicatorTriggerCall, + createIndicatorTriggerSentinel, + isIndicatorTriggerCallId, + TG_INDICATOR_TRIGGER_SENTINEL, + type TriggerCollectorState, +} from '@/lib/indicators/trigger-capture' +import { detectTriggerUsage } from '@/lib/indicators/trigger-detection' +import type { + BarMs, + InputMetaMap, + NormalizedPineOutput, + NormalizedPineSignal, + PineWarning, +} from '@/lib/indicators/types' +import type { ListingIdentity } from '@/lib/listing/identity' + +let browserTriggerShimLock: Promise<void> = Promise.resolve() +let activeTriggerCollector: TriggerCollectorState | null = null + +const CONTEXT_CALL_PATCH_FLAG = '__tg_browser_indicator_trigger_call_patched__' + +const captureTriggerCall = (context: any, args: unknown[]) => { + const state = activeTriggerCollector + if (!state) return + captureIndicatorTriggerCall(state, context, args) +} + +const patchBrowserContextCallForTriggerCapture = () => { + const contextPrototype = Context.prototype as unknown as Record<string, unknown> + if (contextPrototype[CONTEXT_CALL_PATCH_FLAG]) return + + const originalCall = contextPrototype.call as + | ((this: any, fn: (...args: unknown[]) => unknown, id: string, ...args: unknown[]) => unknown) + | undefined + if (typeof originalCall !== 'function') { + throw new Error('PineTS Context.call is unavailable for trigger bridge patching.') + } + + contextPrototype.call = function patchedBrowserIndicatorContextCall( + this: any, + fn: (...args: unknown[]) => unknown, + id: string, + ...args: unknown[] + ) { + const globalTrigger = (globalThis as Record<string, unknown>).trigger + const markedAsTrigger = + typeof fn === 'function' && + ((fn as unknown as Record<string, unknown>)[TG_INDICATOR_TRIGGER_SENTINEL] === true || + fn === globalTrigger) + const triggerById = isIndicatorTriggerCallId(id) + + if (markedAsTrigger || triggerById) { + captureTriggerCall(this, args) + return undefined + } + + return originalCall.apply(this, [fn, id, ...args]) + } + + Object.defineProperty(contextPrototype, CONTEXT_CALL_PATCH_FLAG, { + value: true, + writable: false, + enumerable: false, + configurable: false, + }) +} + +const runWithBrowserTriggerCollector = async <T>(runner: () => Promise<T> | T) => { + const previousLock = browserTriggerShimLock + let releaseLock: () => void = () => {} + browserTriggerShimLock = new Promise<void>((resolve) => { + releaseLock = resolve + }) + await previousLock + + const previousTrigger = (globalThis as { trigger?: (() => void) | undefined }).trigger + const previousCollector = activeTriggerCollector + const collector: TriggerCollectorState = { events: [], warnings: [] } + ;(globalThis as { trigger?: () => void }).trigger = createIndicatorTriggerSentinel() + patchBrowserContextCallForTriggerCapture() + activeTriggerCollector = collector + + try { + const result = await runner() + return { + result, + events: [...collector.events], + warnings: [...collector.warnings], + } + } finally { + activeTriggerCollector = previousCollector + if (previousTrigger === undefined) { + ;(globalThis as { trigger?: () => void }).trigger = undefined + } else { + ;(globalThis as { trigger?: () => void }).trigger = previousTrigger + } + releaseLock() + } +} + +const toPineSymbol = (listing?: ListingIdentity | null) => { + if (!listing) return undefined + if (listing.listing_type === 'default') { + const listingId = listing.listing_id?.trim() + return listingId || undefined + } + const baseId = listing.base_id?.trim() + const quoteId = listing.quote_id?.trim() + if (!baseId || !quoteId) return undefined + return `${baseId}:${quoteId}` +} + +const applyInputVisibilityToggles = ({ + output, + inputMeta, + inputsMap, +}: { + output: NormalizedPineOutput + inputMeta?: InputMetaMap | null + inputsMap: Record<string, unknown> +}) => { + if (!inputMeta) return + + Object.entries(inputMeta).forEach(([title, inputDef]) => { + if (inputDef.type !== 'bool') return + const inputValue = inputsMap[title] + if (inputValue !== false && inputValue !== 0) return + const match = title.match(/^show\s+(.+?)(?:\s+line)?$/i) + if (!match) return + const plotName = match[1]!.toLowerCase() + output.series.forEach((series) => { + if (series.plot.title.toLowerCase().includes(plotName)) { + series.points = series.points.map((point) => ({ ...point, value: null })) + } + }) + }) +} + +export const executeBrowserPineIndicator = async ({ + barsMs, + pineCode, + inputsMap = {}, + inputMeta, + listing, + symbol, + interval, +}: { + barsMs: BarMs[] + pineCode: string + inputsMap?: Record<string, unknown> + inputMeta?: InputMetaMap | null + listing?: ListingIdentity | null + symbol?: string + interval?: string +}): Promise<{ output: NormalizedPineOutput; warnings: PineWarning[] }> => { + const pine = new PineTS(barsMs, symbol ?? toPineSymbol(listing), interval) + await pine.ready() + + let context: any + let triggerSignals: NormalizedPineSignal[] = [] + let triggerWarnings: PineWarning[] = [] + if (detectTriggerUsage(pineCode)) { + const result = await runWithBrowserTriggerCollector(() => + pine.run(new Indicator(pineCode, inputsMap)) + ) + context = result.result + triggerSignals = result.events + triggerWarnings = result.warnings + } else { + context = await pine.run(new Indicator(pineCode, inputsMap)) + } + + const { output, warnings } = normalizeContext({ + context, + ...buildIndexMaps(barsMs), + triggerSignals, + }) + applyInputVisibilityToggles({ output, inputMeta, inputsMap }) + + return { output, warnings: [...warnings, ...triggerWarnings] } +} diff --git a/apps/tradinggoose/lib/indicators/trigger-bridge.ts b/apps/tradinggoose/lib/indicators/trigger-bridge.ts index 9634131ab..5ef332849 100644 --- a/apps/tradinggoose/lib/indicators/trigger-bridge.ts +++ b/apps/tradinggoose/lib/indicators/trigger-bridge.ts @@ -1,192 +1,33 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { Context } from 'pinets' -import type { - IndicatorTriggerSignal, - PineWarning, - SeriesMarkerPosition, -} from '@/lib/indicators/types' +import { + captureIndicatorTriggerCall, + createIndicatorTriggerSentinel, + type IndicatorTriggerCapture, + isIndicatorTriggerCallId, + TG_INDICATOR_TRIGGER_SENTINEL, + type TriggerCollectorState, +} from '@/lib/indicators/trigger-capture' +import type { PineWarning } from '@/lib/indicators/types' + +export { + createIndicatorTriggerSentinel, + INDICATOR_TRIGGER_EVENT_PATTERN, + INDICATOR_TRIGGER_VALID_POSITIONS, + INDICATOR_TRIGGER_VALID_SIGNALS, + type IndicatorTriggerCapture, + TG_INDICATOR_TRIGGER_SENTINEL, +} from '@/lib/indicators/trigger-capture' -export const TG_INDICATOR_TRIGGER_SENTINEL = '__tg_indicator_trigger__' - -export const INDICATOR_TRIGGER_EVENT_PATTERN = /^[a-z][a-z0-9_]{0,63}$/ -export const INDICATOR_TRIGGER_VALID_SIGNALS = ['long', 'short', 'flat'] as const -export const INDICATOR_TRIGGER_VALID_POSITIONS = ['aboveBar', 'belowBar', 'inBar'] as const - -const VALID_SIGNALS = new Set<IndicatorTriggerSignal>(INDICATOR_TRIGGER_VALID_SIGNALS) -const VALID_POSITIONS = new Set<SeriesMarkerPosition>(INDICATOR_TRIGGER_VALID_POSITIONS) const CONTEXT_CALL_PATCH_FLAG = '__tg_indicator_trigger_call_patched__' -const TRIGGER_CALL_ID_PATTERN = /(^|[.$])trigger$/i - -type TriggerCollectorState = { - events: IndicatorTriggerCapture[] - warnings: PineWarning[] -} - -export type IndicatorTriggerCapture = { - event: string - input: string - signal: IndicatorTriggerSignal - position: SeriesMarkerPosition - color?: string - time: number - barIndex: number -} - const collectorStorage = new AsyncLocalStorage<TriggerCollectorState>() -const isRecord = (value: unknown): value is Record<string, unknown> => - typeof value === 'object' && value !== null && !Array.isArray(value) - -const pushWarning = (state: TriggerCollectorState, code: string, message: string) => { - state.warnings.push({ code, message }) -} - -const resolveCurrentValue = (context: any, value: unknown): unknown => { - try { - if (context && typeof context.get === 'function') { - return context.get(value, 0) - } - } catch { - return undefined - } - return value -} - -const resolveTimeSeconds = (context: any): number | null => { - const primary = resolveCurrentValue(context, context?.data?.openTime) - if (typeof primary === 'number' && Number.isFinite(primary)) { - return Math.floor(primary / 1000) - } - - const fallback = resolveCurrentValue(context, context?.data?.time) - if (typeof fallback === 'number' && Number.isFinite(fallback)) { - return Math.floor(fallback / 1000) - } - - return null -} - -const resolvePosition = (rawValue: unknown): SeriesMarkerPosition => { - if (typeof rawValue !== 'string') return 'aboveBar' - const normalized = rawValue.trim() as SeriesMarkerPosition - if (!VALID_POSITIONS.has(normalized)) return 'aboveBar' - return normalized -} - -const resolveColor = (rawValue: unknown): string | undefined => { - if (typeof rawValue !== 'string') return undefined - const trimmed = rawValue.trim() - return trimmed.length > 0 ? trimmed : undefined -} - const captureTriggerCall = (context: any, args: unknown[]) => { const state = collectorStorage.getStore() if (!state) return - - const [eventArg, optionsArg] = args - - const resolvedEvent = resolveCurrentValue(context, eventArg) - const event = typeof resolvedEvent === 'string' ? resolvedEvent.trim() : '' - if (!event || !INDICATOR_TRIGGER_EVENT_PATTERN.test(event)) { - pushWarning( - state, - 'indicator_trigger_invalid_event', - 'trigger(event, options) requires event to match /^[a-z][a-z0-9_]{0,63}$/' - ) - return - } - - const resolvedOptions = resolveCurrentValue(context, optionsArg) - if (!isRecord(resolvedOptions)) { - pushWarning( - state, - 'indicator_trigger_invalid_options', - 'trigger(event, options) requires an options object.' - ) - return - } - - let conditionValue: unknown - try { - conditionValue = resolveCurrentValue(context, resolvedOptions.condition) - } catch { - pushWarning( - state, - 'indicator_trigger_condition_unresolved', - 'trigger options.condition could not be resolved for current bar.' - ) - return - } - if (!conditionValue) { - return - } - - const resolvedInput = resolveCurrentValue(context, resolvedOptions.input) - const input = typeof resolvedInput === 'string' ? resolvedInput.trim() : '' - if (!input) { - pushWarning( - state, - 'indicator_trigger_invalid_input', - 'trigger options.input is required and must be a non-empty string.' - ) - return - } - - const resolvedSignal = resolveCurrentValue(context, resolvedOptions.signal) - const signal = - typeof resolvedSignal === 'string' ? (resolvedSignal.trim() as IndicatorTriggerSignal) : null - if (!signal || !VALID_SIGNALS.has(signal)) { - pushWarning( - state, - 'indicator_trigger_invalid_signal', - 'trigger options.signal must be one of long | short | flat.' - ) - return - } - - const resolvedTimeSeconds = resolveTimeSeconds(context) - if (resolvedTimeSeconds === null) { - pushWarning( - state, - 'indicator_trigger_invalid_time', - 'trigger call dropped because current bar open time is unavailable.' - ) - return - } - - const barIndex = Number.isFinite(context?.idx) ? Number(context.idx) : 0 - const position = resolvePosition(resolveCurrentValue(context, resolvedOptions.position)) - const color = resolveColor(resolveCurrentValue(context, resolvedOptions.color)) - - state.events.push({ - event, - input, - signal, - position, - color, - time: resolvedTimeSeconds, - barIndex, - }) + captureIndicatorTriggerCall(state, context, args) } -export const createIndicatorTriggerSentinel = () => { - const sentinel = function indicatorTriggerSentinelNoop() { - return undefined - } as ((...args: unknown[]) => void) & Record<string, unknown> - - Object.defineProperty(sentinel, TG_INDICATOR_TRIGGER_SENTINEL, { - value: true, - writable: false, - enumerable: false, - configurable: false, - }) - - return sentinel -} - -const isTriggerCallId = (id: unknown) => - typeof id === 'string' && TRIGGER_CALL_ID_PATTERN.test(id.trim()) - export const installIndicatorTriggerSentinel = (target: Record<string, unknown>) => { const existing = target.trigger if ( @@ -208,9 +49,7 @@ export const installIndicatorTriggerSentinel = (target: Record<string, unknown>) const patchContextCallForTriggerCapture = () => { const contextPrototype = Context.prototype as unknown as Record<string, unknown> - if (contextPrototype[CONTEXT_CALL_PATCH_FLAG]) { - return - } + if (contextPrototype[CONTEXT_CALL_PATCH_FLAG]) return const originalCall = contextPrototype.call as | ((this: any, fn: (...args: unknown[]) => unknown, id: string, ...args: unknown[]) => unknown) @@ -230,7 +69,7 @@ const patchContextCallForTriggerCapture = () => { typeof fn === 'function' && ((fn as unknown as Record<string, unknown>)[TG_INDICATOR_TRIGGER_SENTINEL] === true || fn === globalTrigger) - const triggerById = isTriggerCallId(id) + const triggerById = isIndicatorTriggerCallId(id) if (markedAsTrigger || triggerById) { captureTriggerCall(this, args) diff --git a/apps/tradinggoose/lib/indicators/trigger-capture.ts b/apps/tradinggoose/lib/indicators/trigger-capture.ts new file mode 100644 index 000000000..41a2c4625 --- /dev/null +++ b/apps/tradinggoose/lib/indicators/trigger-capture.ts @@ -0,0 +1,177 @@ +import type { + IndicatorTriggerSignal, + PineWarning, + SeriesMarkerPosition, +} from '@/lib/indicators/types' + +export const TG_INDICATOR_TRIGGER_SENTINEL = '__tg_indicator_trigger__' +export const INDICATOR_TRIGGER_EVENT_PATTERN = /^[a-z][a-z0-9_]{0,63}$/ +export const INDICATOR_TRIGGER_VALID_SIGNALS = ['long', 'short', 'flat'] as const +export const INDICATOR_TRIGGER_VALID_POSITIONS = ['aboveBar', 'belowBar', 'inBar'] as const + +const VALID_SIGNALS = new Set<IndicatorTriggerSignal>(INDICATOR_TRIGGER_VALID_SIGNALS) +const VALID_POSITIONS = new Set<SeriesMarkerPosition>(INDICATOR_TRIGGER_VALID_POSITIONS) +const TRIGGER_CALL_ID_PATTERN = /(^|[.$])trigger$/i + +export type TriggerCollectorState = { + events: IndicatorTriggerCapture[] + warnings: PineWarning[] +} + +export type IndicatorTriggerCapture = { + event: string + input: string + signal: IndicatorTriggerSignal + position: SeriesMarkerPosition + color?: string + time: number + barIndex: number +} + +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const pushWarning = (state: TriggerCollectorState, code: string, message: string) => { + state.warnings.push({ code, message }) +} + +const resolveCurrentValue = (context: any, value: unknown): unknown => { + try { + if (context && typeof context.get === 'function') { + return context.get(value, 0) + } + } catch { + return undefined + } + return value +} + +const resolveTimeSeconds = (context: any): number | null => { + const primary = resolveCurrentValue(context, context?.data?.openTime) + if (typeof primary === 'number' && Number.isFinite(primary)) { + return Math.floor(primary / 1000) + } + + const fallback = resolveCurrentValue(context, context?.data?.time) + if (typeof fallback === 'number' && Number.isFinite(fallback)) { + return Math.floor(fallback / 1000) + } + + return null +} + +const resolvePosition = (rawValue: unknown): SeriesMarkerPosition => { + if (typeof rawValue !== 'string') return 'aboveBar' + const normalized = rawValue.trim() as SeriesMarkerPosition + if (!VALID_POSITIONS.has(normalized)) return 'aboveBar' + return normalized +} + +const resolveColor = (rawValue: unknown): string | undefined => { + if (typeof rawValue !== 'string') return undefined + const trimmed = rawValue.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +export const createIndicatorTriggerSentinel = () => { + const sentinel = function indicatorTriggerSentinelNoop() { + return undefined + } as ((...args: unknown[]) => void) & Record<string, unknown> + + Object.defineProperty(sentinel, TG_INDICATOR_TRIGGER_SENTINEL, { + value: true, + writable: false, + enumerable: false, + configurable: false, + }) + + return sentinel +} + +export const isIndicatorTriggerCallId = (id: unknown) => + typeof id === 'string' && TRIGGER_CALL_ID_PATTERN.test(id.trim()) + +export const captureIndicatorTriggerCall = ( + state: TriggerCollectorState, + context: any, + args: unknown[] +) => { + const [eventArg, optionsArg] = args + + const resolvedEvent = resolveCurrentValue(context, eventArg) + const event = typeof resolvedEvent === 'string' ? resolvedEvent.trim() : '' + if (!event || !INDICATOR_TRIGGER_EVENT_PATTERN.test(event)) { + pushWarning( + state, + 'indicator_trigger_invalid_event', + 'trigger(event, options) requires event to match /^[a-z][a-z0-9_]{0,63}$/' + ) + return + } + + const resolvedOptions = resolveCurrentValue(context, optionsArg) + if (!isRecord(resolvedOptions)) { + pushWarning( + state, + 'indicator_trigger_invalid_options', + 'trigger(event, options) requires an options object.' + ) + return + } + + let conditionValue: unknown + try { + conditionValue = resolveCurrentValue(context, resolvedOptions.condition) + } catch { + pushWarning( + state, + 'indicator_trigger_condition_unresolved', + 'trigger options.condition could not be resolved for current bar.' + ) + return + } + if (!conditionValue) return + + const resolvedInput = resolveCurrentValue(context, resolvedOptions.input) + const input = typeof resolvedInput === 'string' ? resolvedInput.trim() : '' + if (!input) { + pushWarning( + state, + 'indicator_trigger_invalid_input', + 'trigger options.input is required and must be a non-empty string.' + ) + return + } + + const resolvedSignal = resolveCurrentValue(context, resolvedOptions.signal) + const signal = + typeof resolvedSignal === 'string' ? (resolvedSignal.trim() as IndicatorTriggerSignal) : null + if (!signal || !VALID_SIGNALS.has(signal)) { + pushWarning( + state, + 'indicator_trigger_invalid_signal', + 'trigger options.signal must be one of long | short | flat.' + ) + return + } + + const time = resolveTimeSeconds(context) + if (time === null) { + pushWarning( + state, + 'indicator_trigger_invalid_time', + 'trigger call dropped because current bar open time is unavailable.' + ) + return + } + + state.events.push({ + event, + input, + signal, + time, + barIndex: Number.isFinite(context?.idx) ? Number(context.idx) : 0, + position: resolvePosition(resolveCurrentValue(context, resolvedOptions.position)), + color: resolveColor(resolveCurrentValue(context, resolvedOptions.color)), + }) +} diff --git a/apps/tradinggoose/lib/json/stable.test.ts b/apps/tradinggoose/lib/json/stable.test.ts new file mode 100644 index 000000000..cc26b4175 --- /dev/null +++ b/apps/tradinggoose/lib/json/stable.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { sortJsonValue, stableStringifyJsonValue } from '@/lib/json/stable' + +describe('stable JSON helpers', () => { + it('sorts object keys recursively before stringifying', () => { + const left = { b: 1, a: { d: 4, c: 3 } } + const right = { a: { c: 3, d: 4 }, b: 1 } + + expect(sortJsonValue(left)).toEqual(right) + expect(stableStringifyJsonValue(left)).toBe(stableStringifyJsonValue(right)) + }) +}) diff --git a/apps/tradinggoose/lib/json/stable.ts b/apps/tradinggoose/lib/json/stable.ts new file mode 100644 index 000000000..fff45dcce --- /dev/null +++ b/apps/tradinggoose/lib/json/stable.ts @@ -0,0 +1,29 @@ +const isPlainRecord = (value: unknown): value is Record<string, unknown> => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +export function sortJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJsonValue) + } + + if (isPlainRecord(value)) { + return Object.keys(value) + .sort() + .reduce<Record<string, unknown>>((sorted, key) => { + const nextValue = sortJsonValue(value[key]) + if (nextValue !== undefined) { + sorted[key] = nextValue + } + return sorted + }, {}) + } + + return value +} + +export function stableStringifyJsonValue(value: unknown): string { + return JSON.stringify(sortJsonValue(value) ?? null) +} diff --git a/apps/tradinggoose/lib/listing/hydrate-ui.ts b/apps/tradinggoose/lib/listing/hydrate-ui.ts index 41d93e391..08a15d8df 100644 --- a/apps/tradinggoose/lib/listing/hydrate-ui.ts +++ b/apps/tradinggoose/lib/listing/hydrate-ui.ts @@ -1,5 +1,5 @@ -import { readServerJsonCache, writeServerJsonCache } from '@/lib/cache/server-json-cache' import { + getListingIdentityKey, type ListingIdentity, type ListingInputValue, type ListingResolved, @@ -15,10 +15,6 @@ import { type ListingRecord = Record<string, unknown> type ListingHydrationCache = Map<string, ListingResolved | null> -const SHARED_LISTING_CACHE_TTL_SECONDS = 5 * 60 - -const buildListingKey = (listing: ListingIdentity) => - `${listing.listing_type}|${listing.listing_id}|${listing.base_id}|${listing.quote_id}` const readText = (value: unknown): string | null => { if (typeof value === 'string') { @@ -90,17 +86,9 @@ const resolveListingValue = async ( if (!listingIdentity) return value if (hasResolvedFields(record, listingIdentity.listing_type)) return value - const key = buildListingKey(listingIdentity) + const key = getListingIdentityKey(listingIdentity) if (!cache.has(key)) { - const sharedCacheKey = `listing-resolve:${key}` - const cachedResolved = await readServerJsonCache<ListingResolved | null>(sharedCacheKey) - const resolved = - cachedResolved ?? (await resolveListingIdentity(listingIdentity).catch(() => null)) - - if (cachedResolved === null && resolved) { - await writeServerJsonCache(sharedCacheKey, resolved, SHARED_LISTING_CACHE_TTL_SECONDS) - } - + const resolved = await resolveListingIdentity(listingIdentity).catch(() => null) cache.set(key, resolved ?? null) } const resolved = cache.get(key) diff --git a/apps/tradinggoose/lib/listing/identity.test.ts b/apps/tradinggoose/lib/listing/identity.test.ts new file mode 100644 index 000000000..13c157352 --- /dev/null +++ b/apps/tradinggoose/lib/listing/identity.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { getListingIdentityKey, toListingValueObject } from '@/lib/listing/identity' + +describe('listing identity helpers', () => { + it('normalizes listing identities and builds canonical keys from one source', () => { + const listing = toListingValueObject({ + listing_id: ' AAPL ', + base_id: 'ignored', + quote_id: 'ignored', + listing_type: 'default', + }) + + expect(listing).toEqual({ + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }) + expect(listing ? getListingIdentityKey(listing) : null).toBe('default|AAPL||') + }) +}) diff --git a/apps/tradinggoose/lib/listing/identity.ts b/apps/tradinggoose/lib/listing/identity.ts index 9cb3b2236..03e708d78 100644 --- a/apps/tradinggoose/lib/listing/identity.ts +++ b/apps/tradinggoose/lib/listing/identity.ts @@ -75,9 +75,10 @@ export const areListingIdentitiesEqual = ( ) } -const normalizeListingIdentity = ( - record: Record<string, unknown> -): ListingIdentity | null => { +export const getListingIdentityKey = (listing: ListingIdentity) => + `${listing.listing_type}|${listing.listing_id}|${listing.base_id}|${listing.quote_id}` + +const normalizeListingIdentity = (record: Record<string, unknown>): ListingIdentity | null => { const listingType = readListingType(record) if (!listingType) return null diff --git a/apps/tradinggoose/lib/listing/resolve.test.ts b/apps/tradinggoose/lib/listing/resolve.test.ts new file mode 100644 index 000000000..ba5d6e7ae --- /dev/null +++ b/apps/tradinggoose/lib/listing/resolve.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment jsdom + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getListingIdentityKey } from '@/lib/listing/identity' +import { buildResolvedListingFromRows, resolveListingIdentities } from '@/lib/listing/resolve' + +describe('listing resolve row hydration', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('builds resolved display metadata through the shared row hydration path', () => { + expect( + buildResolvedListingFromRows( + { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + { + listings: { + AAPL: { + base: 'AAPL', + name: 'Apple Inc.', + assetClass: 'stock', + marketCode: 'XNAS', + }, + }, + currencies: {}, + cryptos: {}, + } + ) + ).toMatchObject({ + listing_id: 'AAPL', + base: 'AAPL', + name: 'Apple Inc.', + assetClass: 'stock', + marketCode: 'XNAS', + }) + }) + + it('resolves default and pair identities through shared batch requests', async () => { + const stockListing = { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default' as const, + } + const currencyListing = { + listing_id: '', + base_id: 'USD', + quote_id: 'EUR', + listing_type: 'currency' as const, + } + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input) + if (url.startsWith('/api/market/get/listing')) { + return new Response( + JSON.stringify({ + data: { + base: 'AAPL', + name: 'Apple Inc.', + assetClass: 'stock', + }, + }), + { status: 200 } + ) + } + + if (url.startsWith('/api/market/get/currency')) { + return new Response( + JSON.stringify({ + data: { + USD: { code: 'USD', name: 'US Dollar' }, + EUR: { code: 'EUR', name: 'Euro' }, + }, + }), + { status: 200 } + ) + } + + throw new Error(`Unexpected market request: ${url}`) + }) + + const resolved = await resolveListingIdentities([stockListing, stockListing, currencyListing]) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(resolved[getListingIdentityKey(stockListing)]).toMatchObject({ + listing_id: 'AAPL', + base: 'AAPL', + name: 'Apple Inc.', + assetClass: 'stock', + }) + expect(resolved[getListingIdentityKey(currencyListing)]).toMatchObject({ + base_id: 'USD', + quote_id: 'EUR', + base: 'USD', + quote: 'EUR', + name: 'US Dollar to Euro pair', + assetClass: 'currency', + }) + }) + + it('forwards abort signals through batch market fetches', async () => { + const controller = new AbortController() + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + data: { + base: 'AAPL', + name: 'Apple Inc.', + }, + }), + { status: 200 } + ) + ) + + await resolveListingIdentities( + [ + { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + ], + controller.signal + ) + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + signal: controller.signal, + }) + ) + }) +}) diff --git a/apps/tradinggoose/lib/listing/resolve.ts b/apps/tradinggoose/lib/listing/resolve.ts index 06ea4c2a2..f4b501106 100644 --- a/apps/tradinggoose/lib/listing/resolve.ts +++ b/apps/tradinggoose/lib/listing/resolve.ts @@ -1,4 +1,9 @@ -import type { ListingIdentity, ListingResolved } from '@/lib/listing/identity' +import { + getListingIdentityKey, + type ListingIdentity, + type ListingResolved, + toListingValueObject, +} from '@/lib/listing/identity' import { MARKET_API_VERSION } from '@/lib/market/client/constants' import { getBaseUrl } from '@/lib/urls/utils' @@ -24,6 +29,12 @@ type MarketSearchResponse<T> = { type CodeRow = { code?: string; name?: string | null; iconUrl?: string | null } +export type ListingResolutionRowMaps = { + listings: Record<string, unknown | null> + currencies: Record<string, unknown | null> + cryptos: Record<string, unknown | null> +} + const buildMarketGetUrl = (path: string, params: URLSearchParams) => { const relativeUrl = `/api/market/get/${path}?${params.toString()}` if (typeof window !== 'undefined') { @@ -33,7 +44,7 @@ const buildMarketGetUrl = (path: string, params: URLSearchParams) => { return new URL(relativeUrl, getBaseUrl()).toString() } -const uniqueNonEmpty = (values: string[]) => { +export const uniqueNonEmpty = (values: string[]) => { const seen = new Set<string>() const result: string[] = [] for (const value of values) { @@ -45,13 +56,17 @@ const uniqueNonEmpty = (values: string[]) => { return result } -const toCodeRow = (row: unknown): CodeRow | null => { +export const toCodeRow = (row: unknown): CodeRow | null => { if (!row || typeof row !== 'object') return null const record = row as CodeRow return { code: record.code, name: record.name ?? null, iconUrl: record.iconUrl ?? null } } -const fetchMarketSearch = async <T>(path: string, params: URLSearchParams): Promise<T | null> => { +export const fetchMarketSearch = async <T>( + path: string, + params: URLSearchParams, + signal?: AbortSignal +): Promise<T | null> => { if (!params.get('version')) { params.set('version', MARKET_API_VERSION) } @@ -61,6 +76,7 @@ const fetchMarketSearch = async <T>(path: string, params: URLSearchParams): Prom headers: { 'Content-Type': 'application/json', }, + signal, }) let payload: MarketSearchResponse<T> | null = null @@ -81,10 +97,11 @@ const fetchMarketSearch = async <T>(path: string, params: URLSearchParams): Prom return payload.data ?? null } -const fetchMarketBatch = async <T>( +export const fetchMarketBatch = async <T>( path: string, paramName: string, - ids: string[] + ids: string[], + signal?: AbortSignal ): Promise<Record<string, T | null>> => { const uniqueIds = uniqueNonEmpty(ids) const result: Record<string, T | null> = {} @@ -92,7 +109,7 @@ const fetchMarketBatch = async <T>( const params = new URLSearchParams() uniqueIds.forEach((id) => params.append(paramName, id)) - const data = await fetchMarketSearch<any>(path, params) + const data = await fetchMarketSearch<any>(path, params, signal) if (!data) { uniqueIds.forEach((id) => { @@ -122,14 +139,19 @@ const fetchMarketBatch = async <T>( return result } -const getBatchRow = async <T>(path: string, paramName: string, id: string): Promise<T | null> => { - const records = await fetchMarketBatch<T>(path, paramName, [id]) +export const getBatchRow = async <T>( + path: string, + paramName: string, + id: string, + signal?: AbortSignal +): Promise<T | null> => { + const records = await fetchMarketBatch<T>(path, paramName, [id], signal) return records[id] ?? null } -const resolveListingById = async (listingId: string): Promise<ResolvedListingDetails | null> => { - const listing = await getBatchRow<any>('listing', 'listing_id', listingId) - if (!listing || typeof listing !== 'object') return null +const buildListingDetailsFromListingRow = (row: unknown): ResolvedListingDetails | null => { + if (!row || typeof row !== 'object') return null + const listing = row as ResolvedListingDetails return { base: listing.base, quote: listing.quote ?? null, @@ -144,25 +166,17 @@ const resolveListingById = async (listingId: string): Promise<ResolvedListingDet } } -const resolveCurrencyById = async ( - currencyId: string -): Promise<{ code?: string; name?: string | null; iconUrl?: string | null } | null> => { - return toCodeRow(await getBatchRow<any>('currency', 'currency_id', currencyId)) -} - -const resolveCryptoById = async ( - cryptoId: string -): Promise<{ code?: string; name?: string | null; iconUrl?: string | null } | null> => { - return toCodeRow(await getBatchRow<any>('crypto', 'crypto_id', cryptoId)) -} - -const resolveCurrencyPair = async ( - baseId: string, - quoteId: string -): Promise<ResolvedListingDetails | null> => { - const records = await fetchMarketBatch<any>('currency', 'currency_id', [baseId, quoteId]) - const baseRow = toCodeRow(records[baseId]) - const quoteRow = toCodeRow(records[quoteId]) +const buildPairDetails = ({ + baseRow, + quoteRow, + assetClass, + quoteAssetClass, +}: { + baseRow: CodeRow | null + quoteRow: CodeRow | null + assetClass: 'currency' | 'crypto' + quoteAssetClass: 'currency' | 'crypto' +}): ResolvedListingDetails | null => { if (!baseRow?.code || !quoteRow?.code) return null const baseName = baseRow.name?.trim() || baseRow.code const quoteName = quoteRow.name?.trim() || quoteRow.code @@ -171,91 +185,152 @@ const resolveCurrencyPair = async ( quote: quoteRow.code, name: `${baseName} to ${quoteName} pair`, iconUrl: baseRow.iconUrl ?? null, - assetClass: 'currency', - base_asset_class: 'currency', - quote_asset_class: 'currency', + assetClass, + base_asset_class: assetClass, + quote_asset_class: quoteAssetClass, } } -const resolveCryptoPair = async ( - baseId: string, - quoteId: string -): Promise<ResolvedListingDetails | null> => { - const records = await fetchMarketBatch<any>('crypto', 'crypto_id', [baseId, quoteId]) - const baseRow = toCodeRow(records[baseId]) - const quoteRow = toCodeRow(records[quoteId]) - if (!baseRow?.code || !quoteRow?.code) return null - const baseName = baseRow.name?.trim() || baseRow.code - const quoteName = quoteRow.name?.trim() || quoteRow.code - return { - base: baseRow.code, - quote: quoteRow.code, - name: baseName && quoteName ? `${baseName} to ${quoteName} pair` : null, - iconUrl: baseRow.iconUrl ?? null, - assetClass: 'crypto', - base_asset_class: 'crypto', - quote_asset_class: 'crypto', - } -} - -const resolveCryptoWithCurrencyQuote = async ( - baseId: string, - quoteId: string -): Promise<ResolvedListingDetails | null> => { - const [cryptoRecords, currencyRecords] = await Promise.all([ - fetchMarketBatch<any>('crypto', 'crypto_id', [baseId]), - fetchMarketBatch<any>('currency', 'currency_id', [quoteId]), - ]) - const baseRow = toCodeRow(cryptoRecords[baseId]) - const quoteRow = toCodeRow(currencyRecords[quoteId]) - if (!baseRow?.code || !quoteRow?.code) return null - const baseName = baseRow.name?.trim() || baseRow.code - const quoteName = quoteRow.name?.trim() || quoteRow.code - return { - base: baseRow.code, - quote: quoteRow.code, - name: `${baseName} to ${quoteName} pair`, - iconUrl: baseRow.iconUrl ?? null, - assetClass: 'crypto', - base_asset_class: 'crypto', - quote_asset_class: 'currency', - } -} - -export async function resolveListingIdentity( - listing: ListingIdentity -): Promise<ListingResolved | null> { - if (!listing) return null - +export const buildListingDetailsFromRows = ( + listing: ListingIdentity, + rows: ListingResolutionRowMaps +): ResolvedListingDetails | null => { const listingType = listing.listing_type - const listingId = listing.listing_id?.trim() - const baseId = listing.base_id?.trim() - const quoteId = listing.quote_id?.trim() + const listingId = listing.listing_id.trim() + const baseId = listing.base_id.trim() + const quoteId = listing.quote_id.trim() if (listingType === 'default') { if (!listingId) return null - const details = await resolveListingById(listingId) - return details ? buildResolvedListing(listing, details) : null + return buildListingDetailsFromListingRow(rows.listings[listingId]) } if (!baseId || !quoteId) return null if (listingType === 'currency') { - const details = await resolveCurrencyPair(baseId, quoteId) - return details ? buildResolvedListing(listing, details) : null + return buildPairDetails({ + baseRow: toCodeRow(rows.currencies[baseId]), + quoteRow: toCodeRow(rows.currencies[quoteId]), + assetClass: 'currency', + quoteAssetClass: 'currency', + }) } if (listingType === 'crypto') { const isCryptoQuote = quoteId.toUpperCase().includes('CRYP') - const details = isCryptoQuote - ? await resolveCryptoPair(baseId, quoteId) - : await resolveCryptoWithCurrencyQuote(baseId, quoteId) - return details ? buildResolvedListing(listing, details) : null + return buildPairDetails({ + baseRow: toCodeRow(rows.cryptos[baseId]), + quoteRow: toCodeRow(isCryptoQuote ? rows.cryptos[quoteId] : rows.currencies[quoteId]), + assetClass: 'crypto', + quoteAssetClass: isCryptoQuote ? 'crypto' : 'currency', + }) } return null } +export const buildResolvedListingFromRows = ( + listing: ListingIdentity, + rows: ListingResolutionRowMaps +): ListingResolved | null => { + const details = buildListingDetailsFromRows(listing, rows) + return details ? buildResolvedListing(listing, details) : null +} + +export async function resolveListingIdentity( + listing: ListingIdentity, + signal?: AbortSignal +): Promise<ListingResolved | null> { + const normalized = toListingValueObject(listing) + if (!normalized) return null + const rowMaps = await fetchListingResolutionRowMaps([normalized], signal) + try { + return buildResolvedListingFromRows(normalized, rowMaps) + } catch { + return null + } +} + +const fetchListingResolutionRowMaps = async ( + listings: readonly ListingIdentity[], + signal?: AbortSignal +): Promise<ListingResolutionRowMaps> => { + const identities = new Map<string, ListingIdentity>() + + for (const listing of listings) { + const normalized = toListingValueObject(listing) + if (!normalized) continue + const key = getListingIdentityKey(normalized) + if (!identities.has(key)) { + identities.set(key, normalized) + } + } + + const listingIds: string[] = [] + const currencyIds: string[] = [] + const cryptoIds: string[] = [] + + identities.forEach((listing) => { + if (listing.listing_type === 'default') { + listingIds.push(listing.listing_id) + return + } + + if (listing.listing_type === 'currency') { + currencyIds.push(listing.base_id, listing.quote_id) + return + } + + cryptoIds.push(listing.base_id) + if (listing.quote_id.toUpperCase().includes('CRYP')) { + cryptoIds.push(listing.quote_id) + } else { + currencyIds.push(listing.quote_id) + } + }) + + const [listingRows, currencyRows, cryptoRows] = await Promise.all([ + fetchMarketBatch<any>('listing', 'listing_id', listingIds, signal), + fetchMarketBatch<any>('currency', 'currency_id', currencyIds, signal), + fetchMarketBatch<any>('crypto', 'crypto_id', cryptoIds, signal), + ]) + + return { + listings: listingRows, + currencies: currencyRows, + cryptos: cryptoRows, + } +} + +export async function resolveListingIdentities( + listings: readonly ListingIdentity[], + signal?: AbortSignal +): Promise<Record<string, ListingResolved | null>> { + const identities = new Map<string, ListingIdentity>() + + for (const listing of listings) { + const normalized = toListingValueObject(listing) + if (!normalized) continue + const key = getListingIdentityKey(normalized) + if (!identities.has(key)) { + identities.set(key, normalized) + } + } + + const rowMaps = await fetchListingResolutionRowMaps(Array.from(identities.values()), signal) + + const resolved: Record<string, ListingResolved | null> = {} + identities.forEach((listing, key) => { + try { + resolved[key] = buildResolvedListingFromRows(listing, rowMaps) + } catch { + resolved[key] = null + } + }) + + return resolved +} + function buildResolvedListing( listing: ListingIdentity, details: ResolvedListingDetails diff --git a/apps/tradinggoose/lib/markdown/public-page-markdown.ts b/apps/tradinggoose/lib/markdown/public-page-markdown.ts index ce2072fea..aec9a1884 100644 --- a/apps/tradinggoose/lib/markdown/public-page-markdown.ts +++ b/apps/tradinggoose/lib/markdown/public-page-markdown.ts @@ -1,9 +1,10 @@ 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 { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import { localizeUrl, stripLocaleFromPathname, type LocaleCode } from '@/i18n/utils' interface MarkdownDocumentOptions { title: string @@ -45,7 +46,8 @@ function plainTextTitle(value: string): string { .trim() } -async function buildHomepageMarkdown(origin: string): Promise<string> { +async function buildHomepageMarkdown(origin: string, locale: LocaleCode): Promise<string> { + const copy = getPublicCopy(locale) const billingCatalog = await getPublicBillingCatalog() const hostedPricingSentence = billingCatalog.billingEnabled ? buildHostedPricingSentence(billingCatalog) @@ -53,7 +55,7 @@ async function buildHomepageMarkdown(origin: string): Promise<string> { const body = `# TradingGoose -${DEFAULT_META_DESCRIPTION} +${copy.meta.landing.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 @@ -81,9 +83,9 @@ ${ - Documentation: https://docs.tradinggoose.ai - GitHub: https://github.com/TradingGoose/TradingGoose-Studio -- Sign up: ${origin}/signup -- Changelog: ${origin}/changelog -- Pricing and plans: ${origin} +- Sign up: ${localizeUrl(origin, locale, '/signup')} +- Changelog: ${localizeUrl(origin, locale, '/changelog')} +- Pricing and plans: ${localizeUrl(origin, locale, '/')} ## Community @@ -92,24 +94,25 @@ ${ ` return buildMarkdownDocument({ - title: 'TradingGoose - Visual Workflow Platform for Technical LLM Trading', - description: DEFAULT_META_DESCRIPTION, - url: `${origin}/`, + title: copy.meta.landing.title, + description: copy.meta.landing.description, + url: localizeUrl(origin, locale, '/'), body, }) } -async function buildBlogIndexMarkdown(origin: string): Promise<string> { - const posts = await getAllPosts() +async function buildBlogIndexMarkdown(origin: string, locale: LocaleCode): Promise<string> { + const copy = getPublicCopy(locale) + const posts = await getAllPosts(locale) 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}` + return `- [${title}](${localizeUrl(origin, locale, `/blog/${post.slug}`)}) (${post.date})${description}` }) - const body = `# TradingGoose Blog + const body = `# ${copy.blog.pageTitle} -Articles about trading automation, workflow design, and building smarter strategies. +${formatTemplate(copy.blog.pageDescription, { count: posts.length })} ## Posts @@ -117,17 +120,20 @@ ${lines.join('\n')} ` return buildMarkdownDocument({ - title: 'Blog | TradingGoose', - description: - 'Articles about trading automation, workflow design, and building smarter strategies.', - url: `${origin}/blog`, + title: copy.meta.blog.title, + description: copy.meta.blog.description, + url: localizeUrl(origin, locale, '/blog'), body, }) } -async function buildBlogPostMarkdown(origin: string, pathname: string): Promise<string | null> { +async function buildBlogPostMarkdown( + origin: string, + pathname: string, + locale: LocaleCode +): Promise<string | null> { const slug = pathname.replace(/^\/blog\//, '') - const post = await getPostBySlug(slug) + const post = await getPostBySlug(slug, locale) if (!post) { return null @@ -156,7 +162,7 @@ ${post.content.trim()} return buildMarkdownDocument({ title, description: post.description, - url: `${origin}${pathname}`, + url: localizeUrl(origin, locale, pathname), body, }) } @@ -251,16 +257,18 @@ export async function renderPublicPageMarkdown( origin: string, pathname: string ): Promise<string | null> { - switch (pathname) { + const { locale, pathname: normalizedPathname } = stripLocaleFromPathname(pathname) + + switch (normalizedPathname) { case '/': - return buildHomepageMarkdown(origin) + return buildHomepageMarkdown(origin, locale) case '/blog': - return buildBlogIndexMarkdown(origin) + return buildBlogIndexMarkdown(origin, locale) case '/changelog': return buildChangelogMarkdown(origin) default: - if (pathname.startsWith('/blog/')) { - return buildBlogPostMarkdown(origin, pathname) + if (normalizedPathname.startsWith('/blog/')) { + return buildBlogPostMarkdown(origin, normalizedPathname, locale) } return buildConvertedPageMarkdown(origin, pathname) diff --git a/apps/tradinggoose/lib/market/client/client.ts b/apps/tradinggoose/lib/market/client/client.ts index 112452283..ec9293129 100644 --- a/apps/tradinggoose/lib/market/client/client.ts +++ b/apps/tradinggoose/lib/market/client/client.ts @@ -1,6 +1,5 @@ import { createLogger } from '@/lib/logs/console/logger' -import { MARKET_API_URL_DEFAULT } from '@/lib/market/client/constants' -import { resolveMarketApiServiceConfig } from '@/lib/system-services/runtime' +import { requestTradingGooseMarket } from '@/lib/market/request-gate' import { generateRequestId } from '@/lib/utils' const logger = createLogger('MarketClient') @@ -13,14 +12,6 @@ export interface MarketClientResponse<T = any> { } class MarketClient { - private async getServiceConfig() { - const config = await resolveMarketApiServiceConfig() - return { - baseUrl: config.baseUrl || MARKET_API_URL_DEFAULT, - apiKey: config.apiKey, - } - } - async makeRequest<T = any>( endpoint: string, options: { @@ -50,9 +41,6 @@ class MarketClient { } try { - const serviceConfig = await this.getServiceConfig() - const url = `${serviceConfig.baseUrl}${endpoint}` - const requestHeaders: Record<string, string> = { ...headers, } @@ -61,13 +49,8 @@ class MarketClient { requestHeaders['Content-Type'] = 'application/json' } - const key = apiKey ?? serviceConfig.apiKey - if (key) { - requestHeaders['x-api-key'] = key - } - logger.info(`[${requestId}] Making request to market service`, { - url, + endpoint, method, }) @@ -80,7 +63,10 @@ class MarketClient { fetchOptions.body = JSON.stringify(body) } - const response = await fetch(url, fetchOptions) + const response = await requestTradingGooseMarket(endpoint, { + ...fetchOptions, + apiKey, + }) const responseStatus = response.status let responseData: any diff --git a/apps/tradinggoose/lib/market/market-provider-settings.test.ts b/apps/tradinggoose/lib/market/market-provider-settings.test.ts new file mode 100644 index 000000000..817cb188b --- /dev/null +++ b/apps/tradinggoose/lib/market/market-provider-settings.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + sanitizeMarketProviderAuth, + sanitizeMarketProviderParamsForWidget, +} from '@/lib/market/market-provider-settings' + +describe('market provider settings helpers', () => { + it('keeps raw and env-var market auth credentials', () => { + expect( + sanitizeMarketProviderAuth({ + apiKey: 'raw-key', + apiSecret: '{{ ALPACA_API_SECRET }}', + }) + ).toEqual({ + apiKey: 'raw-key', + apiSecret: '{{ ALPACA_API_SECRET }}', + }) + }) + + it('drops blank market auth credentials', () => { + expect( + sanitizeMarketProviderAuth({ + apiKey: '{{ ALPACA_API_KEY }}', + apiSecret: ' ', + }) + ).toEqual({ + apiKey: '{{ ALPACA_API_KEY }}', + }) + }) + + it('strips misplaced auth params while preserving non-secret provider params', () => { + expect( + sanitizeMarketProviderParamsForWidget('alpaca', { + apiKey: '{{ ALPACA_API_KEY }}', + apiSecret: 'raw', + feed: 'iex', + }) + ).toEqual({ + feed: 'iex', + }) + }) + + it('preserves unknown non-secret params without recursively classifying nested keys', () => { + expect( + sanitizeMarketProviderParamsForWidget(undefined, { + apiKey: 'raw', + tokenPayload: { + apiSecret: 'nested raw value', + }, + blank: ' ', + }) + ).toEqual({ + tokenPayload: { + apiSecret: 'nested raw value', + }, + }) + }) +}) diff --git a/apps/tradinggoose/lib/market/market-provider-settings.ts b/apps/tradinggoose/lib/market/market-provider-settings.ts new file mode 100644 index 000000000..59a41e48b --- /dev/null +++ b/apps/tradinggoose/lib/market/market-provider-settings.ts @@ -0,0 +1,88 @@ +import { + coerceMarketProviderParamValue, + getMarketProviderParamDefinitions, + type MarketProviderParamDefinition, +} from '@/providers/market/providers' + +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const trimProviderId = (providerId?: string) => + typeof providerId === 'string' ? providerId.trim() : '' + +const readCredentialString = (value: unknown) => { + if (typeof value !== 'string') return undefined + return value.trim() ? value : undefined +} + +export const isMarketProviderCredentialDefinition = (definition: MarketProviderParamDefinition) => + definition.password === true || definition.id === 'apiKey' || definition.id === 'apiSecret' + +export const resolveMarketProviderSettingsDefinitions = ( + providerId?: string +): MarketProviderParamDefinition[] => { + const trimmedProviderId = trimProviderId(providerId) + if (!trimmedProviderId) return [] + + return getMarketProviderParamDefinitions(trimmedProviderId, 'series').filter((definition) => { + if (definition.visibility === 'hidden' || definition.visibility === 'llm-only') return false + return definition.required === true || isMarketProviderCredentialDefinition(definition) + }) +} + +export const sanitizeMarketProviderAuth = ( + auth: unknown +): { apiKey?: string; apiSecret?: string } | undefined => { + if (!isRecord(auth)) return undefined + + const nextAuth: { apiKey?: string; apiSecret?: string } = {} + const apiKey = readCredentialString(auth.apiKey) + const apiSecret = readCredentialString(auth.apiSecret) + + if (apiKey) nextAuth.apiKey = apiKey + if (apiSecret) nextAuth.apiSecret = apiSecret + + return Object.keys(nextAuth).length > 0 ? nextAuth : undefined +} + +export const sanitizeMarketProviderParamsForWidget = ( + providerId: string | undefined, + providerParams: unknown +): Record<string, unknown> | undefined => { + if (!isRecord(providerParams)) return undefined + + const trimmedProviderId = trimProviderId(providerId) + const definitions = trimmedProviderId + ? getMarketProviderParamDefinitions(trimmedProviderId, 'series') + : [] + const definitionsById = new Map(definitions.map((definition) => [definition.id, definition])) + const nextParams: Record<string, unknown> = {} + + for (const [key, value] of Object.entries(providerParams)) { + if (key === 'apiKey' || key === 'apiSecret') continue + if (typeof value === 'string' && value.trim() === '') continue + + const definition = definitionsById.get(key) + if (!definition) { + nextParams[key] = value + continue + } + + if (isMarketProviderCredentialDefinition(definition)) { + const credentialValue = readCredentialString(value) + if (credentialValue) nextParams[key] = credentialValue + continue + } + + try { + const coerced = coerceMarketProviderParamValue(definition, value) + if (coerced !== undefined && coerced !== null && coerced !== '') { + nextParams[key] = coerced + } + } catch { + // Invalid typed provider params should not be persisted into widget params. + } + } + + return Object.keys(nextParams).length > 0 ? nextParams : undefined +} diff --git a/apps/tradinggoose/lib/market/quote-snapshot-contract.ts b/apps/tradinggoose/lib/market/quote-snapshot-contract.ts new file mode 100644 index 000000000..3d47a8f56 --- /dev/null +++ b/apps/tradinggoose/lib/market/quote-snapshot-contract.ts @@ -0,0 +1,19 @@ +export type MarketQuoteSnapshot = { + lastPrice: number | null + change: number | null + changePercent: number | null + previousClose: number | null + volume?: number | null + volumeUsd?: number | null + error?: string +} + +export const MARKET_QUOTE_SNAPSHOT_REQUEST_CAP = 200 + +export const createEmptyMarketQuoteSnapshot = (error?: string): MarketQuoteSnapshot => ({ + lastPrice: null, + change: null, + changePercent: null, + previousClose: null, + ...(error ? { error } : {}), +}) diff --git a/apps/tradinggoose/lib/market/quote-snapshots.test.ts b/apps/tradinggoose/lib/market/quote-snapshots.test.ts new file mode 100644 index 000000000..f27cc30d7 --- /dev/null +++ b/apps/tradinggoose/lib/market/quote-snapshots.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildMarketQuoteSnapshot } from '@/lib/market/quote-snapshots' + +const mockExecuteProviderRequest = vi.fn() + +vi.mock('@/providers/market', () => ({ + executeProviderRequest: (...args: unknown[]) => mockExecuteProviderRequest(...args), +})) + +describe('buildMarketQuoteSnapshot', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses regular intraday last price with prior daily close for quote math', async () => { + mockExecuteProviderRequest.mockImplementation(async (_providerId, request: any) => { + if (request.interval === '1d') { + return { + bars: [ + { timeStamp: '2026-01-01T00:00:00.000Z', close: 100 }, + { timeStamp: '2026-01-02T00:00:00.000Z', close: 105 }, + ], + } + } + + return { + bars: [{ timeStamp: '2026-01-03T15:59:00.000Z', close: 110 }], + } + }) + + await expect( + buildMarketQuoteSnapshot({ + provider: 'alpaca', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }) + ).resolves.toEqual({ + lastPrice: 110, + previousClose: 100, + change: 10, + changePercent: 10, + }) + }) + + it('derives volume USD from the latest daily volume and resolved last price', async () => { + mockExecuteProviderRequest.mockImplementation(async (_providerId, request: any) => { + if (request.interval === '1d') { + return { + bars: [ + { timeStamp: '2026-01-01T00:00:00.000Z', close: 100, volume: 10 }, + { timeStamp: '2026-01-02T00:00:00.000Z', close: 105, volume: 20 }, + ], + } + } + + return { + bars: [{ timeStamp: '2026-01-03T15:59:00.000Z', close: 110 }], + } + }) + + await expect( + buildMarketQuoteSnapshot({ + provider: 'alpaca', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }) + ).resolves.toMatchObject({ + volume: 20, + volumeUsd: 2200, + }) + }) +}) diff --git a/apps/tradinggoose/lib/market/quote-snapshots.ts b/apps/tradinggoose/lib/market/quote-snapshots.ts new file mode 100644 index 000000000..e3d8c8c69 --- /dev/null +++ b/apps/tradinggoose/lib/market/quote-snapshots.ts @@ -0,0 +1,134 @@ +import type { ListingIdentity } from '@/lib/listing/identity' +import { + createEmptyMarketQuoteSnapshot, + type MarketQuoteSnapshot, +} from '@/lib/market/quote-snapshot-contract' +import { executeProviderRequest } from '@/providers/market' +import type { MarketSeries } from '@/providers/market/types' + +export { + createEmptyMarketQuoteSnapshot, + type MarketQuoteSnapshot, +} from '@/lib/market/quote-snapshot-contract' + +const normalizeSeries = (value: unknown): MarketSeries | null => { + if (!value || typeof value !== 'object') return null + const series = value as MarketSeries + if (!Array.isArray(series.bars)) return null + return series +} + +const resolveNumber = (value: unknown) => + typeof value === 'number' && Number.isFinite(value) ? value : null + +const buildDailyRequest = async ({ + provider, + listing, + auth, + providerParams, +}: { + provider: string + listing: ListingIdentity + auth?: { apiKey?: string; apiSecret?: string } + providerParams?: Record<string, unknown> +}) => { + const response = await executeProviderRequest(provider, { + kind: 'series', + listing, + interval: '1d', + windows: [{ mode: 'bars', barCount: 2 }], + auth, + providerParams: { + ...(providerParams ?? {}), + marketSession: 'regular', + }, + }) + + return normalizeSeries(response) +} + +const buildRegularLastRequest = async ({ + provider, + listing, + auth, + providerParams, +}: { + provider: string + listing: ListingIdentity + auth?: { apiKey?: string; apiSecret?: string } + providerParams?: Record<string, unknown> +}) => { + try { + const response = await executeProviderRequest(provider, { + kind: 'series', + listing, + interval: '1m', + windows: [{ mode: 'bars', barCount: 1 }], + auth, + providerParams: { + ...(providerParams ?? {}), + allowEmpty: true, + marketSession: 'regular', + }, + }) + + return normalizeSeries(response) + } catch { + return null + } +} + +export const buildMarketQuoteSnapshot = async ({ + provider, + listing, + auth, + providerParams, +}: { + provider: string + listing: ListingIdentity + auth?: { apiKey?: string; apiSecret?: string } + providerParams?: Record<string, unknown> +}): Promise<MarketQuoteSnapshot> => { + try { + const daily = await buildDailyRequest({ provider, listing, auth, providerParams }) + const dailyBars = daily?.bars ?? [] + const latestDaily = dailyBars[dailyBars.length - 1] + const previousDaily = dailyBars[dailyBars.length - 2] + const latestDailyClose = resolveNumber(latestDaily?.close) + const latestDailyVolume = resolveNumber(latestDaily?.volume) + const previousDailyClose = resolveNumber(previousDaily?.close) + const previousClose = + previousDailyClose !== null + ? previousDailyClose + : latestDailyClose !== null + ? latestDailyClose + : null + const regular = await buildRegularLastRequest({ provider, listing, auth, providerParams }) + const regularBar = regular?.bars?.[regular.bars.length - 1] + const regularLastPrice = resolveNumber(regularBar?.close) + const lastPrice = regularLastPrice ?? latestDailyClose + const volumeUsd = + latestDailyVolume !== null && lastPrice !== null ? latestDailyVolume * lastPrice : null + const change = + typeof lastPrice === 'number' && typeof previousClose === 'number' + ? lastPrice - previousClose + : null + const changePercent = + typeof change === 'number' && typeof previousClose === 'number' && previousClose !== 0 + ? (change / previousClose) * 100 + : null + + return { + lastPrice, + change, + changePercent, + previousClose, + ...(latestDailyVolume !== null ? { volume: latestDailyVolume } : {}), + ...(volumeUsd !== null ? { volumeUsd } : {}), + } + } catch (error) { + return createEmptyMarketQuoteSnapshot( + error instanceof Error ? error.message : 'Failed to fetch snapshot' + ) + } +} diff --git a/apps/tradinggoose/lib/market/request-gate.test.ts b/apps/tradinggoose/lib/market/request-gate.test.ts new file mode 100644 index 000000000..682bc1ca4 --- /dev/null +++ b/apps/tradinggoose/lib/market/request-gate.test.ts @@ -0,0 +1,173 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + fetchMock, + mockReadServerJsonCache, + mockResolveMarketApiServiceConfig, + mockWriteServerJsonCache, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + mockReadServerJsonCache: vi.fn(), + mockResolveMarketApiServiceConfig: vi.fn(), + mockWriteServerJsonCache: vi.fn(), +})) + +vi.mock('@/lib/cache/server-json-cache', () => ({ + readServerJsonCache: (...args: unknown[]) => mockReadServerJsonCache(...args), + writeServerJsonCache: (...args: unknown[]) => mockWriteServerJsonCache(...args), +})) + +vi.mock('@/lib/system-services/runtime', () => ({ + resolveMarketApiServiceConfig: (...args: unknown[]) => mockResolveMarketApiServiceConfig(...args), +})) + +describe('TradingGoose Market request gate', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + mockResolveMarketApiServiceConfig.mockResolvedValue({ + apiKey: 'market-secret', + baseUrl: 'https://market.example.com', + }) + }) + + it('returns cached search responses before fetching upstream', async () => { + mockReadServerJsonCache.mockResolvedValue({ + body: '{"data":[]}', + headers: [['content-type', 'application/json']], + status: 200, + }) + + const { requestTradingGooseMarket } = await import('./request-gate') + const response = await requestTradingGooseMarket('/api/search?version=v1') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: [] }) + expect(fetchMock).not.toHaveBeenCalled() + expect(mockWriteServerJsonCache).not.toHaveBeenCalled() + }) + + it('uses a global cache key independent of caller headers and query param order', async () => { + mockReadServerJsonCache.mockResolvedValue(null) + fetchMock.mockImplementation( + () => + new Response('{"data":[]}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + + const { requestTradingGooseMarket } = await import('./request-gate') + await requestTradingGooseMarket('/api/search?b=2&a=1', { + headers: { 'x-user-id': 'user-1' }, + }) + await requestTradingGooseMarket('/api/search?a=1&b=2', { + headers: { 'x-user-id': 'user-2' }, + }) + + expect(mockReadServerJsonCache.mock.calls[0]?.[0]).toBe( + mockReadServerJsonCache.mock.calls[1]?.[0] + ) + expect(mockWriteServerJsonCache.mock.calls[0]?.[0]).toBe( + mockWriteServerJsonCache.mock.calls[1]?.[0] + ) + }) + + it('deduplicates concurrent identical get misses in the current process', async () => { + mockReadServerJsonCache.mockResolvedValue(null) + let resolveFetch: (value: Response) => void = () => {} + fetchMock.mockImplementation( + () => + new Promise<Response>((resolve) => { + resolveFetch = resolve + }) + ) + + const { requestTradingGooseMarket } = await import('./request-gate') + const first = requestTradingGooseMarket('/api/get/listing?id=AAPL') + const second = requestTradingGooseMarket('/api/get/listing?id=AAPL') + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(fetchMock).toHaveBeenCalledTimes(1) + + resolveFetch( + new Response('{"data":{"id":"AAPL"}}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + + const [firstResponse, secondResponse] = await Promise.all([first, second]) + expect(await firstResponse.json()).toEqual({ data: { id: 'AAPL' } }) + expect(await secondResponse.json()).toEqual({ data: { id: 'AAPL' } }) + expect(mockWriteServerJsonCache).toHaveBeenCalledTimes(1) + }) + + it('does not read or write cache for update requests', async () => { + mockReadServerJsonCache.mockResolvedValue({ + body: '{"cached":true}', + headers: [['content-type', 'application/json']], + status: 200, + }) + fetchMock.mockResolvedValue( + new Response('{"fresh":true}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + + const { requestTradingGooseMarket } = await import('./request-gate') + const response = await requestTradingGooseMarket('/api/update/listing-rank', { + body: '{"listing_id":"AAPL"}', + method: 'POST', + }) + + expect(mockReadServerJsonCache).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(await response.json()).toEqual({ fresh: true }) + expect(mockWriteServerJsonCache).not.toHaveBeenCalled() + }) + + it('does not cache validate-key requests', async () => { + mockReadServerJsonCache.mockResolvedValue({ + body: '{"cached":true}', + headers: [['content-type', 'application/json']], + status: 200, + }) + fetchMock.mockResolvedValue( + new Response('{"fresh":true}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + + const { requestTradingGooseMarket } = await import('./request-gate') + const response = await requestTradingGooseMarket('/api/validate-key/get-api-keys', { + body: '{"userId":"user-1"}', + method: 'POST', + }) + + expect(mockReadServerJsonCache).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(await response.json()).toEqual({ fresh: true }) + expect(mockWriteServerJsonCache).not.toHaveBeenCalled() + }) + + it('injects the central TradingGoose-Market service credential', async () => { + mockReadServerJsonCache.mockResolvedValue(null) + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })) + + const { requestTradingGooseMarket } = await import('./request-gate') + await requestTradingGooseMarket('/api/search?version=v1', { + headers: { 'x-api-key': 'caller-key' }, + }) + + const headers = fetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('x-api-key')).toBe('market-secret') + }) +}) diff --git a/apps/tradinggoose/lib/market/request-gate.ts b/apps/tradinggoose/lib/market/request-gate.ts new file mode 100644 index 000000000..3097c8f72 --- /dev/null +++ b/apps/tradinggoose/lib/market/request-gate.ts @@ -0,0 +1,98 @@ +import { createHash } from 'crypto' +import { readServerJsonCache, writeServerJsonCache } from '@/lib/cache/server-json-cache' +import { MARKET_API_URL_DEFAULT } from '@/lib/market/client/constants' +import { resolveMarketApiServiceConfig } from '@/lib/system-services/runtime' + +const CACHE_PREFIX = 'market:request:v1:' +const CACHE_TTL_SECONDS = 60 * 5 +const STRIP_CACHED_HEADERS = new Set(['content-encoding', 'content-length', 'transfer-encoding']) +const inFlight = new Map<string, Promise<CachedMarketResponse>>() + +type CachedMarketResponse = { + body: string + headers: Array<[string, string]> + status: number +} + +export type TradingGooseMarketRequestInit = RequestInit & { + apiKey?: string | null +} + +const hash = (value: string) => createHash('sha256').update(value).digest('hex') + +const cacheKeyForUrl = (rawUrl: string) => { + const url = new URL(rawUrl) + const sortedParams = new URLSearchParams( + Array.from(url.searchParams.entries()).sort(([leftKey, leftValue], [rightKey, rightValue]) => { + const keyComparison = leftKey.localeCompare(rightKey) + return keyComparison === 0 ? leftValue.localeCompare(rightValue) : keyComparison + }) + ) + url.search = sortedParams.toString() + return `${CACHE_PREFIX}${hash(url.toString())}` +} + +const isCacheable = (url: string, method: string) => { + if (method !== 'GET') return false + const pathname = new URL(url).pathname + return ( + pathname === '/api/search' || + pathname.startsWith('/api/search/') || + pathname === '/api/get' || + pathname.startsWith('/api/get/') + ) +} + +const toResponse = (cached: CachedMarketResponse) => + new Response(cached.body, { + headers: new Headers(cached.headers), + status: cached.status, + }) + +const toCachedResponse = async (response: Response): Promise<CachedMarketResponse> => ({ + body: await response.text(), + headers: Array.from(response.headers.entries()).filter( + ([key]) => !STRIP_CACHED_HEADERS.has(key.toLowerCase()) + ), + status: response.status, +}) + +export async function requestTradingGooseMarket( + endpoint: string, + init: TradingGooseMarketRequestInit = {} +): Promise<Response> { + const { apiKey, headers, method: rawMethod = 'GET', ...rest } = init + const method = rawMethod.toUpperCase() + const marketApi = await resolveMarketApiServiceConfig() + const url = new URL(endpoint, marketApi.baseUrl || MARKET_API_URL_DEFAULT).toString() + const requestHeaders = new Headers(headers) + const resolvedApiKey = apiKey === undefined ? marketApi.apiKey : apiKey + + if (!requestHeaders.get('content-type')) requestHeaders.set('content-type', 'application/json') + if (resolvedApiKey) requestHeaders.set('x-api-key', resolvedApiKey) + else requestHeaders.delete('x-api-key') + + const requestInit: RequestInit = { ...rest, cache: 'no-store', headers: requestHeaders, method } + if (!isCacheable(url, method)) return fetch(url, requestInit) + + const cacheKey = cacheKeyForUrl(url) + const cached = await readServerJsonCache<CachedMarketResponse>(cacheKey) + if (cached) return toResponse(cached) + + const pending = inFlight.get(cacheKey) + if (pending) return toResponse(await pending) + + const request = (async () => { + const response = await fetch(url, requestInit) + const cachedResponse = await toCachedResponse(response) + if (response.ok) await writeServerJsonCache(cacheKey, cachedResponse, CACHE_TTL_SECONDS) + return cachedResponse + })() + + inFlight.set(cacheKey, request) + try { + return toResponse(await request) + } finally { + inFlight.delete(cacheKey) + } +} diff --git a/apps/tradinggoose/lib/naming.ts b/apps/tradinggoose/lib/naming.ts index 17c249920..74685f779 100644 --- a/apps/tradinggoose/lib/naming.ts +++ b/apps/tradinggoose/lib/naming.ts @@ -4,6 +4,8 @@ import type { WorkflowFolder } from '@/stores/folders/store' import type { Workspace } from '@/stores/organization/types' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' export interface NameableEntity { name: string @@ -188,18 +190,22 @@ export function generateIncrementalName<T extends NameableEntity>( /** * Generates the next workspace name */ -export async function generateWorkspaceName(): Promise<string> { - const response = await fetch('/api/workspaces') +export async function generateWorkspaceName(locale: LocaleCode): Promise<string> { + const response = await fetch('/api/workspaces?autoCreate=false', { + headers: { + 'x-next-intl-locale': locale, + }, + }) const data = (await response.json()) as WorkspacesApiResponse const workspaces = data.workspaces || [] - return generateIncrementalName(workspaces, 'Workspace') + return generateIncrementalName(workspaces, getPublicCopy(locale).workspace.naming.workspacePrefix) } /** * Generates the next folder name for a workspace */ -export async function generateFolderName(workspaceId: string): Promise<string> { +export async function generateFolderName(workspaceId: string, locale: LocaleCode): Promise<string> { const response = await fetch(`/api/folders?workspaceId=${workspaceId}`) const data = (await response.json()) as FoldersApiResponse const folders = data.folders || [] @@ -207,7 +213,7 @@ export async function generateFolderName(workspaceId: string): Promise<string> { // Filter to only root-level folders (parentId is null) const rootFolders = folders.filter((folder) => folder.parentId === null) - return generateIncrementalName(rootFolders, 'Folder') + return generateIncrementalName(rootFolders, getPublicCopy(locale).workspace.naming.folderPrefix) } /** @@ -215,7 +221,8 @@ export async function generateFolderName(workspaceId: string): Promise<string> { */ export async function generateSubfolderName( workspaceId: string, - parentFolderId: string + parentFolderId: string, + locale: LocaleCode ): Promise<string> { const response = await fetch(`/api/folders?workspaceId=${workspaceId}`) const data = (await response.json()) as FoldersApiResponse @@ -224,7 +231,10 @@ export async function generateSubfolderName( // Filter to only subfolders of the specified parent const subfolders = folders.filter((folder) => folder.parentId === parentFolderId) - return generateIncrementalName(subfolders, 'Subfolder') + return generateIncrementalName( + subfolders, + getPublicCopy(locale).workspace.naming.subfolderPrefix + ) } /** diff --git a/apps/tradinggoose/lib/oauth/oauth.test.ts b/apps/tradinggoose/lib/oauth/oauth.test.ts index d90d0876a..65373d34a 100644 --- a/apps/tradinggoose/lib/oauth/oauth.test.ts +++ b/apps/tradinggoose/lib/oauth/oauth.test.ts @@ -6,8 +6,8 @@ type MockOAuthCredentials = { fields: Record<string, string> } -const mockCredentials: Record<string, MockOAuthCredentials> = {} -const mockEnvValues: Record<string, string> = {} +const mockCredentials: Record<string, MockOAuthCredentials | undefined> = {} +const mockEnvValues: Record<string, string | undefined> = {} vi.mock('@/lib/oauth/system-managed-config', () => ({ loadSystemOAuthClientCredentials: vi.fn(async (providerIds: string[]) => @@ -20,8 +20,8 @@ vi.mock('@/lib/oauth/system-managed-config', () => ({ ) ) ), - loadSystemOAuthClientCredentialsForProvider: vi.fn(async (providerId: string) => - mockCredentials[providerId] ?? null + loadSystemOAuthClientCredentialsForProvider: vi.fn( + async (providerId: string) => mockCredentials[providerId] ?? null ), })) @@ -41,7 +41,11 @@ vi.mock('@/lib/logs/console/logger', () => ({ const mockFetch = vi.fn() global.fetch = mockFetch -import { getOAuthProviderSubjectId, getServiceIdFromScopes } from '@/lib/oauth/oauth' +import { + getOAuthProviderSubjectId, + getServiceIdFromScopes, + getServiceIdsFromScopes, +} from '@/lib/oauth/oauth' import { getOAuthProviderAvailability, refreshOAuthToken } from '@/lib/oauth/oauth.server' function setIntegration(providerIds: string[], clientId: string, clientSecret: string) { @@ -92,7 +96,14 @@ function seedMockIntegrations() { ) setIntegration(['github-repo'], 'github_repo_client_id', 'github_repo_client_secret') setIntegration( - ['microsoft-excel', 'microsoft-teams', 'microsoft-planner', 'outlook', 'onedrive', 'sharepoint'], + [ + 'microsoft-excel', + 'microsoft-teams', + 'microsoft-planner', + 'outlook', + 'onedrive', + 'sharepoint', + ], 'microsoft_client_id', 'microsoft_client_secret' ) @@ -109,7 +120,7 @@ function seedMockIntegrations() { setIntegration(['wealthbox'], 'wealthbox_client_id', 'wealthbox_client_secret') setIntegration(['webflow'], 'webflow_client_id', 'webflow_client_secret') setIntegration(['tradier'], 'tradier_client_id', 'tradier_client_secret') - setIntegration(['alpaca'], 'alpaca_client_id', 'alpaca_client_secret') + setIntegration(['alpaca-live', 'alpaca-paper'], 'alpaca_client_id', 'alpaca_client_secret') setIntegration(['hubspot'], 'hubspot_client_id', 'hubspot_client_secret') setApiKeyIntegration(['trello'], 'trello_api_key') @@ -175,7 +186,7 @@ describe('OAuth Provider Availability', () => { }) it('does not make Trello available from TRELLO_API_KEY env fallback', async () => { - delete mockCredentials.trello + mockCredentials.trello = undefined mockEnvValues.TRELLO_API_KEY = 'env-trello-api-key' await expect(getOAuthProviderAvailability(['trello'])).resolves.toEqual({ @@ -184,8 +195,8 @@ describe('OAuth Provider Availability', () => { }) it('returns false for env-backed social sign-in providers when required env credentials are missing', async () => { - delete mockEnvValues.GITHUB_CLIENT_SECRET - delete mockEnvValues.GOOGLE_CLIENT_ID + mockEnvValues.GITHUB_CLIENT_SECRET = undefined + mockEnvValues.GOOGLE_CLIENT_ID = undefined await expect(getOAuthProviderAvailability(['github', 'google'])).resolves.toEqual({ github: false, @@ -231,9 +242,22 @@ describe('OAuth Subject Normalization', () => { }) it('keeps direct service providers stable without re-entering scope branching', () => { - expect(getServiceIdFromScopes('google-drive', ['https://www.googleapis.com/auth/drive.file'])).toBe( - 'google-drive' - ) + expect( + getServiceIdFromScopes('google-drive', ['https://www.googleapis.com/auth/drive.file']) + ).toBe('google-drive') + }) + + it('does not collapse ambiguous Alpaca scopes to live', () => { + expect(getServiceIdsFromScopes('alpaca', ['trading', 'data'])).toEqual([ + 'alpaca-live', + 'alpaca-paper', + ]) + expect( + getOAuthProviderSubjectId({ + provider: 'alpaca', + requiredScopes: ['trading', 'data'], + }) + ).toBeNull() }) }) @@ -403,7 +427,7 @@ describe('OAuth Token Refresh', () => { }, { name: 'Alpaca', - providerId: 'alpaca', + providerId: 'alpaca-live', endpoint: 'https://api.alpaca.markets/oauth/token', expectedClientId: 'alpaca_client_id', expectedClientSecret: 'alpaca_client_secret', diff --git a/apps/tradinggoose/lib/oauth/oauth.ts b/apps/tradinggoose/lib/oauth/oauth.ts index b50af534f..49c54a6e9 100644 --- a/apps/tradinggoose/lib/oauth/oauth.ts +++ b/apps/tradinggoose/lib/oauth/oauth.ts @@ -3,6 +3,7 @@ import { AirtableIcon, ConfluenceIcon, DiscordIcon, + DollarIcon, GithubIcon, GmailIcon, GoogleCalendarIcon, @@ -14,7 +15,6 @@ import { HubspotIcon, JiraIcon, LinearIcon, - DollarIcon, MicrosoftExcelIcon, MicrosoftIcon, MicrosoftOneDriveIcon, @@ -53,7 +53,8 @@ export type OAuthProvider = | string export type OAuthService = - | 'alpaca' // <-- here + | 'alpaca-live' + | 'alpaca-paper' | 'google' | 'google-email' | 'google-drive' @@ -114,12 +115,15 @@ const DEFAULT_OAUTH_CREDENTIAL_FIELDS: OAuthCredentialFieldConfig[] = [ oauthProperty: 'clientSecret', }, ] + +const ALPACA_OAUTH_SCOPES = ['trading', 'data'] + export interface OAuthProviderConfig { id: OAuthProvider name: string icon: (props: { className?: string }) => ReactNode services: Record<string, OAuthServiceConfig> - defaultService: string + defaultService?: string credentialProvider?: string credentialFields?: OAuthCredentialFieldConfig[] } @@ -167,17 +171,25 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = { name: 'Alpaca', icon: (props) => AlpacaIcon(props), services: { - alpaca: { - id: 'alpaca', - name: 'Alpaca', - description: 'Trade and manage accounts with Alpaca.', - providerId: 'alpaca', + 'alpaca-live': { + id: 'alpaca-live', + name: 'Alpaca Live', + description: 'Trade and manage an Alpaca live account.', + providerId: 'alpaca-live', icon: (props) => AlpacaIcon(props), baseProviderIcon: (props) => AlpacaIcon(props), - scopes: ['account:write', 'trading', 'data'], + scopes: ALPACA_OAUTH_SCOPES, + }, + 'alpaca-paper': { + id: 'alpaca-paper', + name: 'Alpaca Paper', + description: 'Trade and manage an Alpaca paper account.', + providerId: 'alpaca-paper', + icon: (props) => AlpacaIcon(props), + baseProviderIcon: (props) => AlpacaIcon(props), + scopes: ALPACA_OAUTH_SCOPES, }, }, - defaultService: 'alpaca', }, google: { id: 'google', @@ -723,27 +735,40 @@ export function getServiceByProviderAndId( getOAuthServiceLookupEntry(normalizedProvider)?.serviceId || providerConfig.defaultService - return providerConfig.services[resolvedServiceId] || providerConfig.services[providerConfig.defaultService] + if (resolvedServiceId && providerConfig.services[resolvedServiceId]) { + return providerConfig.services[resolvedServiceId] + } + + if (providerConfig.defaultService && providerConfig.services[providerConfig.defaultService]) { + return providerConfig.services[providerConfig.defaultService] + } + + throw new Error(`Service ${serviceId ?? normalizedProvider} not found`) } // Helper function to determine service ID from scopes export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[]): string { + const serviceIds = getServiceIdsFromScopes(provider, scopes) + return serviceIds.length === 1 ? serviceIds[0]! : normalizeOAuthIdentifier(provider) +} + +export function getServiceIdsFromScopes(provider: OAuthProvider, scopes: string[]): string[] { const normalizedProvider = normalizeOAuthIdentifier(provider) const directService = getOAuthServiceLookupEntry(normalizedProvider) if (directService) { - return directService.serviceId + return [directService.serviceId] } const providerConfig = OAUTH_PROVIDERS[normalizedProvider] if (!providerConfig) { - return normalizedProvider + return [normalizedProvider] } - const normalizedScopes = Array.from( - new Set(scopes.map(normalizeOAuthScope).filter(Boolean)) - ) + const normalizedScopes = Array.from(new Set(scopes.map(normalizeOAuthScope).filter(Boolean))) if (normalizedScopes.length === 0) { return providerConfig.defaultService + ? [providerConfig.defaultService] + : Object.keys(providerConfig.services) } const matchingServices = Object.values(providerConfig.services).filter((service) => { @@ -751,16 +776,16 @@ export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[] return normalizedScopes.every((scope) => serviceScopes.has(scope)) }) - if (matchingServices.length === 1) { - return matchingServices[0]!.id + if (matchingServices.length > 0) { + return matchingServices.map((service) => service.id) } const hintedServiceId = resolveServiceIdFromScopeHints(providerConfig.id, normalizedScopes) if (hintedServiceId) { - return hintedServiceId + return [hintedServiceId] } - return providerConfig.defaultService + return providerConfig.defaultService ? [providerConfig.defaultService] : [] } // Helper function to get provider ID from service ID @@ -794,14 +819,15 @@ export interface ProviderConfig { export type OAuthProviderAvailability = Record<string, boolean> -const OAUTH_SERVICE_ENTRIES = Object.entries(OAUTH_PROVIDERS).flatMap(([baseProvider, providerConfig]) => - Object.entries(providerConfig.services).map(([featureType, service]) => ({ - baseProvider, - featureType, - serviceId: service.id, - providerId: service.providerId, - scopes: service.scopes, - })) +const OAUTH_SERVICE_ENTRIES = Object.entries(OAUTH_PROVIDERS).flatMap( + ([baseProvider, providerConfig]) => + Object.entries(providerConfig.services).map(([featureType, service]) => ({ + baseProvider, + featureType, + serviceId: service.id, + providerId: service.providerId, + scopes: service.scopes, + })) ) as OAuthServiceLookupEntry[] const OAUTH_PROVIDER_LOOKUP = Object.fromEntries( @@ -851,7 +877,11 @@ const OAUTH_SCOPE_HINTS: Record<string, Array<{ serviceId: string; patterns: str function getOAuthServiceLookupEntry(identifier: string): OAuthServiceLookupEntry | null { const normalizedIdentifier = normalizeOAuthIdentifier(identifier) - return OAUTH_SERVICE_LOOKUP[normalizedIdentifier] ?? OAUTH_PROVIDER_LOOKUP[normalizedIdentifier] ?? null + return ( + OAUTH_SERVICE_LOOKUP[normalizedIdentifier] ?? + OAUTH_PROVIDER_LOOKUP[normalizedIdentifier] ?? + null + ) } function resolveOAuthProviderConfig(identifier: string) { @@ -909,9 +939,7 @@ export function getCanonicalScopesForProvider(providerId: string): string[] { const serviceLookup = OAUTH_PROVIDER_LOOKUP[normalizedProviderId] ?? OAUTH_SERVICE_LOOKUP[normalizedProviderId] - return serviceLookup?.scopes - ? [...serviceLookup.scopes] - : [] + return serviceLookup?.scopes ? [...serviceLookup.scopes] : [] } export function getBaseProviderForService(providerId: string): string { @@ -928,7 +956,8 @@ export function getBaseProviderForService(providerId: string): string { */ export function parseProvider(provider: OAuthProvider): ProviderConfig { const normalizedProvider = normalizeOAuthIdentifier(provider) - const mapping = OAUTH_PROVIDER_LOOKUP[normalizedProvider] ?? OAUTH_SERVICE_LOOKUP[normalizedProvider] + const mapping = + OAUTH_PROVIDER_LOOKUP[normalizedProvider] ?? OAUTH_SERVICE_LOOKUP[normalizedProvider] if (mapping) { return { baseProvider: mapping.baseProvider, @@ -961,13 +990,18 @@ export function getOAuthProviderSubjectId(input: { return null } + const directService = getOAuthServiceLookupEntry(provider) + if (directService) { + return directService.providerId + } + if (requiredScopes.length > 0) { - const derivedServiceId = getServiceIdFromScopes(provider as OAuthProvider, requiredScopes) - return getProviderIdFromServiceId(derivedServiceId) + const derivedServiceIds = getServiceIdsFromScopes(provider as OAuthProvider, requiredScopes) + return derivedServiceIds.length === 1 ? getProviderIdFromServiceId(derivedServiceIds[0]!) : null } const providerConfig = resolveOAuthProviderConfig(provider) - if (providerConfig) { + if (providerConfig?.defaultService) { return getProviderIdFromServiceId(providerConfig.defaultService) } diff --git a/apps/tradinggoose/lib/registration/shared.ts b/apps/tradinggoose/lib/registration/shared.ts index a6bab40ee..43e83c817 100644 --- a/apps/tradinggoose/lib/registration/shared.ts +++ b/apps/tradinggoose/lib/registration/shared.ts @@ -42,14 +42,3 @@ export function getAuthRegistrationHref(mode: RegistrationMode) { return null } } - -export function getAuthRegistrationLabel(mode: RegistrationMode) { - switch (mode) { - case 'open': - return 'Sign up' - case 'waitlist': - return 'Join waitlist' - case 'disabled': - return null - } -} diff --git a/apps/tradinggoose/lib/system-integrations/resolver.ts b/apps/tradinggoose/lib/system-integrations/resolver.ts index 0d60c8a81..79b2c930b 100644 --- a/apps/tradinggoose/lib/system-integrations/resolver.ts +++ b/apps/tradinggoose/lib/system-integrations/resolver.ts @@ -82,17 +82,26 @@ export async function resolveSystemIntegrationDefinitions( } async function listDefinitionsById() { + const catalog = getSystemIntegrationCatalogSeedSnapshot() const rows = await db.select().from(systemIntegrationDefinition) + const persistedDefinitionsById = new Map(rows.map((row) => [row.id, row])) + return new Map( - rows.map((row) => [ - row.id, - { - id: row.id, - parentId: row.parentId, - name: row.name, - isEnabled: row.isEnabled, - } satisfies SystemIntegrationDefinitionRecord, - ]) + catalog.definitions.map((definition) => { + const persistedDefinition = persistedDefinitionsById.get(definition.id) + + return [ + definition.id, + { + id: definition.id, + parentId: persistedDefinition?.parentId ?? definition.parentId, + name: definition.name, + isEnabled: definition.parentId + ? (persistedDefinition?.isEnabled ?? definition.isEnabled) + : null, + } satisfies SystemIntegrationDefinitionRecord, + ] + }) ) } diff --git a/apps/tradinggoose/lib/watchlists/operations.ts b/apps/tradinggoose/lib/watchlists/operations.ts index f04ad4765..18a706399 100644 --- a/apps/tradinggoose/lib/watchlists/operations.ts +++ b/apps/tradinggoose/lib/watchlists/operations.ts @@ -2,7 +2,11 @@ import { db } from '@tradinggoose/db' import { watchlistItem, watchlistTable } from '@tradinggoose/db/schema' import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm' import type { ListingIdentity, ListingInputValue } from '@/lib/listing/identity' -import { areListingIdentitiesEqual, toListingValueObject } from '@/lib/listing/identity' +import { + areListingIdentitiesEqual, + getListingIdentityKey, + toListingValueObject, +} from '@/lib/listing/identity' import { DEFAULT_WATCHLIST_NAME, MAX_SYMBOLS_PER_WATCHLIST } from '@/lib/watchlists/constants' import type { WatchlistImportFileItem, @@ -153,7 +157,10 @@ const buildItemsBySectionMap = (items: WatchlistItemRow[]) => { return { bySection, unsectioned } } -const composeWatchlistItems = (sections: WatchlistRow[], items: WatchlistItemRow[]): WatchlistItem[] => { +const composeWatchlistItems = ( + sections: WatchlistRow[], + items: WatchlistItemRow[] +): WatchlistItem[] => { const output: WatchlistItem[] = [] const sortedSections = [...sections].sort((a, b) => { if (a.sortOrder !== b.sortOrder) { @@ -285,9 +292,6 @@ const hasListingIdentity = (items: WatchlistItemRow[], candidate: ListingIdentit return existing ? areListingIdentitiesEqual(existing, candidate) : false }) -const getListingIdentityKey = (listing: ListingIdentity) => - `${listing.listing_type}|${listing.listing_id}|${listing.base_id}|${listing.quote_id}` - const flattenImportedWatchlistListings = (items: WatchlistImportFileItem[]) => items.flatMap<WatchlistImportFileListingItem>((item) => item.type === 'listing' ? [item] : item.items @@ -536,7 +540,9 @@ export async function deleteWatchlist(scope: WatchlistScope, watchlistId: string await db.transaction(async (tx) => { const row = await fetchWatchlistRow(tx, watchlistId, scope) ensureMutableList(row, 'delete') - await tx.delete(watchlistTable).where(and(eq(watchlistTable.id, row.id), isNull(watchlistTable.parentId))) + await tx + .delete(watchlistTable) + .where(and(eq(watchlistTable.id, row.id), isNull(watchlistTable.parentId))) }) } @@ -767,9 +773,7 @@ export async function removeWatchlistSection( await tx .delete(watchlistItem) - .where( - and(eq(watchlistItem.watchlistId, row.id), eq(watchlistItem.containerId, sectionId)) - ) + .where(and(eq(watchlistItem.watchlistId, row.id), eq(watchlistItem.containerId, sectionId))) await tx .delete(watchlistTable) @@ -888,9 +892,7 @@ export async function appendWatchlistItemsToWatchlist( for (const item of importedListings) { const listing = toListingValueObject(item.listing) const key = listing ? getListingIdentityKey(listing) : null - const isDuplicate = key - ? existingListingKeys.has(key) || plannedAdditionKeys.has(key) - : true + const isDuplicate = key ? existingListingKeys.has(key) || plannedAdditionKeys.has(key) : true if (!listing || !key || isDuplicate) { skippedCount += 1 diff --git a/apps/tradinggoose/lib/workflows/autolayout/containers.ts b/apps/tradinggoose/lib/workflows/autolayout/containers.ts index fe103d6ea..84005806c 100644 --- a/apps/tradinggoose/lib/workflows/autolayout/containers.ts +++ b/apps/tradinggoose/lib/workflows/autolayout/containers.ts @@ -20,10 +20,9 @@ export function layoutContainers( edges: Edge[], options: LayoutOptions = {} ): void { - const { root, children } = getBlocksByParent(blocks) + const { children } = getBlocksByParent(blocks) const containerOptions: LayoutOptions = { - direction: options.direction, horizontalSpacing: options.horizontalSpacing ? options.horizontalSpacing * 0.85 : 400, verticalSpacing: options.verticalSpacing ? options.verticalSpacing : 200, padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y }, @@ -52,7 +51,7 @@ export function layoutContainers( const childNodes = assignLayers(childBlocks, childEdges) prepareBlockMetrics(childNodes) const childLayers = groupByLayer(childNodes) - calculatePositions(childLayers, containerOptions) + calculatePositions(childLayers, childEdges, containerOptions) let minX = Number.POSITIVE_INFINITY let minY = Number.POSITIVE_INFINITY diff --git a/apps/tradinggoose/lib/workflows/autolayout/incremental.ts b/apps/tradinggoose/lib/workflows/autolayout/incremental.ts deleted file mode 100644 index c56c4f066..000000000 --- a/apps/tradinggoose/lib/workflows/autolayout/incremental.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { createLogger } from '@/lib/logs/console/logger' -import type { BlockState } from '@/stores/workflows/workflow/types' -import type { AdjustmentOptions, Edge } from './types' -import { boxesOverlap, createBoundingBox, getBlockMetrics } from './utils' - -const logger = createLogger('AutoLayout:Incremental') - -const DEFAULT_SHIFT_SPACING = 550 - -export function adjustForNewBlock( - blocks: Record<string, BlockState>, - edges: Edge[], - newBlockId: string, - options: AdjustmentOptions = {} -): void { - const newBlock = blocks[newBlockId] - if (!newBlock) { - logger.warn('New block not found in blocks', { newBlockId }) - return - } - - const shiftSpacing = options.horizontalSpacing ?? DEFAULT_SHIFT_SPACING - - const incomingEdges = edges.filter((e) => e.target === newBlockId) - const outgoingEdges = edges.filter((e) => e.source === newBlockId) - - if (incomingEdges.length === 0 && outgoingEdges.length === 0) { - logger.debug('New block has no connections, no adjustment needed', { newBlockId }) - return - } - - const sourceBlocks = incomingEdges - .map((e) => blocks[e.source]) - .filter((b) => b !== undefined && b.id !== newBlockId) - - if (sourceBlocks.length > 0) { - const avgSourceX = sourceBlocks.reduce((sum, b) => sum + b.position.x, 0) / sourceBlocks.length - const avgSourceY = sourceBlocks.reduce((sum, b) => sum + b.position.y, 0) / sourceBlocks.length - const maxSourceX = Math.max(...sourceBlocks.map((b) => b.position.x)) - - newBlock.position = { - x: maxSourceX + shiftSpacing, - y: avgSourceY, - } - - logger.debug('Positioned new block based on source blocks', { - newBlockId, - position: newBlock.position, - sourceCount: sourceBlocks.length, - }) - } - - const targetBlocks = outgoingEdges - .map((e) => blocks[e.target]) - .filter((b) => b !== undefined && b.id !== newBlockId) - - if (targetBlocks.length > 0 && sourceBlocks.length === 0) { - const minTargetX = Math.min(...targetBlocks.map((b) => b.position.x)) - const avgTargetY = targetBlocks.reduce((sum, b) => sum + b.position.y, 0) / targetBlocks.length - - newBlock.position = { - x: Math.max(150, minTargetX - shiftSpacing), - y: avgTargetY, - } - - logger.debug('Positioned new block based on target blocks', { - newBlockId, - position: newBlock.position, - targetCount: targetBlocks.length, - }) - } - - const newBlockMetrics = getBlockMetrics(newBlock) - const newBlockBox = createBoundingBox(newBlock.position, newBlockMetrics) - - const blocksToShift: Array<{ block: BlockState; shiftAmount: number }> = [] - - for (const [id, block] of Object.entries(blocks)) { - if (id === newBlockId) continue - if (block.data?.parentId) continue - - if (block.position.x >= newBlock.position.x) { - const blockMetrics = getBlockMetrics(block) - const blockBox = createBoundingBox(block.position, blockMetrics) - - if (boxesOverlap(newBlockBox, blockBox, 50)) { - const requiredShift = newBlock.position.x + newBlockMetrics.width + 50 - block.position.x - if (requiredShift > 0) { - blocksToShift.push({ block, shiftAmount: requiredShift }) - } - } - } - } - - if (blocksToShift.length > 0) { - logger.debug('Shifting blocks to accommodate new block', { - newBlockId, - shiftCount: blocksToShift.length, - }) - - for (const { block, shiftAmount } of blocksToShift) { - block.position.x += shiftAmount - } - } -} - -export function compactHorizontally(blocks: Record<string, BlockState>, edges: Edge[]): void { - const blockArray = Object.values(blocks).filter((b) => !b.data?.parentId) - - blockArray.sort((a, b) => a.position.x - b.position.x) - - const MIN_SPACING = 500 - - for (let i = 1; i < blockArray.length; i++) { - const prevBlock = blockArray[i - 1] - const currentBlock = blockArray[i] - - const prevMetrics = getBlockMetrics(prevBlock) - const expectedX = prevBlock.position.x + prevMetrics.width + MIN_SPACING - - if (currentBlock.position.x > expectedX + 150) { - const shift = currentBlock.position.x - expectedX - currentBlock.position.x = expectedX - - logger.debug('Compacted block horizontally', { - blockId: currentBlock.id, - shift, - }) - } - } -} diff --git a/apps/tradinggoose/lib/workflows/autolayout/index.ts b/apps/tradinggoose/lib/workflows/autolayout/index.ts index ed659b3f9..115e5763e 100644 --- a/apps/tradinggoose/lib/workflows/autolayout/index.ts +++ b/apps/tradinggoose/lib/workflows/autolayout/index.ts @@ -1,10 +1,9 @@ import { createLogger } from '@/lib/logs/console/logger' import type { BlockState } from '@/stores/workflows/workflow/types' import { layoutContainers } from './containers' -import { adjustForNewBlock as adjustForNewBlockInternal, compactHorizontally } from './incremental' import { assignLayers, groupByLayer } from './layering' import { calculatePositions } from './positioning' -import type { AdjustmentOptions, Edge, LayoutOptions, LayoutResult, Loop, Parallel } from './types' +import type { Edge, LayoutOptions, LayoutResult } from './types' import { getBlocksByParent, prepareBlockMetrics } from './utils' const logger = createLogger('AutoLayout') @@ -12,20 +11,18 @@ const logger = createLogger('AutoLayout') export function applyAutoLayout( blocks: Record<string, BlockState>, edges: Edge[], - loops: Record<string, Loop> = {}, - parallels: Record<string, Parallel> = {}, options: LayoutOptions = {} ): LayoutResult { try { logger.info('Starting auto layout', { blockCount: Object.keys(blocks).length, edgeCount: edges.length, - loopCount: Object.keys(loops).length, - parallelCount: Object.keys(parallels).length, }) const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks)) + layoutContainers(blocksCopy, edges, options) + const { root: rootBlockIds } = getBlocksByParent(blocksCopy) const rootBlocks: Record<string, BlockState> = {} @@ -41,7 +38,7 @@ export function applyAutoLayout( const nodes = assignLayers(rootBlocks, rootEdges) prepareBlockMetrics(nodes) const layers = groupByLayer(nodes) - calculatePositions(layers, options) + calculatePositions(layers, rootEdges, options) for (const node of nodes.values()) { blocksCopy[node.id].position = node.position @@ -68,38 +65,5 @@ export function applyAutoLayout( } } -export function adjustForNewBlock( - blocks: Record<string, BlockState>, - edges: Edge[], - newBlockId: string, - options: AdjustmentOptions = {} -): LayoutResult { - try { - logger.info('Adjusting layout for new block', { newBlockId }) - - const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks)) - - adjustForNewBlockInternal(blocksCopy, edges, newBlockId, options) - - if (!options.preservePositions) { - compactHorizontally(blocksCopy, edges) - } - - return { - blocks: blocksCopy, - success: true, - } - } catch (error) { - logger.error('Failed to adjust layout for new block', { newBlockId, error }) - return { - blocks, - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - } - } -} - -export type { LayoutOptions, LayoutResult, AdjustmentOptions, Edge, Loop, Parallel } -export type { TargetedLayoutOptions } from './targeted' -export { applyTargetedLayout } from './targeted' +export type { LayoutOptions, LayoutResult, Edge } export { getBlockMetrics, isContainerType } from './utils' diff --git a/apps/tradinggoose/lib/workflows/autolayout/positioning.ts b/apps/tradinggoose/lib/workflows/autolayout/positioning.ts index 59e6bcfa5..18b168ddb 100644 --- a/apps/tradinggoose/lib/workflows/autolayout/positioning.ts +++ b/apps/tradinggoose/lib/workflows/autolayout/positioning.ts @@ -1,6 +1,5 @@ import { createLogger } from '@/lib/logs/console/logger' -import type { AutoLayoutDirection } from '@/lib/workflows/workflow-direction' -import type { GraphNode, LayoutOptions } from './types' +import type { Edge, GraphNode, LayoutOptions } from './types' import { boxesOverlap, createBoundingBox } from './utils' const logger = createLogger('AutoLayout:Positioning') @@ -8,57 +7,23 @@ const logger = createLogger('AutoLayout:Positioning') const DEFAULT_HORIZONTAL_SPACING = 550 const DEFAULT_VERTICAL_SPACING = 200 const DEFAULT_PADDING = { x: 150, y: 150 } +type LayoutAxis = 'horizontal' | 'vertical' export function calculatePositions( layers: Map<number, GraphNode[]>, + edges: Edge[], options: LayoutOptions = {} ): void { - const direction = options.direction ?? 'horizontal' const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING const padding = options.padding ?? DEFAULT_PADDING const alignment = options.alignment ?? 'center' const layerNumbers = Array.from(layers.keys()).sort((a, b) => a - b) + let xPosition = padding.x - // Calculate positions for each layer for (const layerNum of layerNumbers) { const nodesInLayer = layers.get(layerNum)! - if (direction === 'vertical') { - const yPosition = padding.y + layerNum * verticalSpacing - const totalWidth = nodesInLayer.reduce( - (sum, node, idx) => sum + node.metrics.width + (idx > 0 ? horizontalSpacing : 0), - 0 - ) - - let xOffset: number - switch (alignment) { - case 'start': - xOffset = padding.x - break - case 'center': - xOffset = Math.max(padding.x, 300 - totalWidth / 2) - break - case 'end': - xOffset = 900 - totalWidth - padding.x - break - default: - xOffset = padding.x - break - } - - for (const node of nodesInLayer) { - node.position = { - x: xOffset, - y: yPosition, - } - - xOffset += node.metrics.width + horizontalSpacing - } - continue - } - - const xPosition = padding.x + layerNum * horizontalSpacing const totalHeight = nodesInLayer.reduce( (sum, node, idx) => sum + node.metrics.height + (idx > 0 ? verticalSpacing : 0), 0 @@ -88,15 +53,115 @@ export function calculatePositions( yOffset += node.metrics.height + verticalSpacing } + + xPosition += Math.max( + horizontalSpacing, + Math.max(0, ...nodesInLayer.map((node) => node.metrics.width)) + 120 + ) + } + + const incomingAxes = applyEdgeConstraints(layers, edges, horizontalSpacing, verticalSpacing) + + resolveOverlaps( + Array.from(layers.values()).flat(), + incomingAxes, + horizontalSpacing, + verticalSpacing + ) +} + +function applyEdgeConstraints( + layers: Map<number, GraphNode[]>, + edges: Edge[], + horizontalSpacing: number, + verticalSpacing: number +): Map<string, LayoutAxis | 'mixed'> { + const nodesById = new Map(Array.from(layers.values()).flat().map((node) => [node.id, node])) + const incomingEdges = new Map<string, Edge[]>() + const incomingAxes = new Map<string, LayoutAxis | 'mixed'>() + + for (const edge of edges) { + if (!incomingEdges.has(edge.target)) { + incomingEdges.set(edge.target, []) + } + incomingEdges.get(edge.target)!.push(edge) + } + + for (const layerNum of Array.from(layers.keys()).sort((a, b) => a - b)) { + const layer = layers.get(layerNum)! + for (const node of layer) { + const nodeIncomingEdges = incomingEdges.get(node.id) ?? [] + let horizontalX: number | null = null + let horizontalY: number | null = null + let verticalX: number | null = null + let verticalY: number | null = null + + for (const edge of nodeIncomingEdges) { + const source = nodesById.get(edge.source) + if (!source) continue + + const axis = getEdgeAxis(edge, source) + const currentAxis = incomingAxes.get(node.id) + incomingAxes.set(node.id, !currentAxis || currentAxis === axis ? axis : 'mixed') + + if (axis === 'horizontal') { + horizontalX = Math.max( + horizontalX ?? Number.NEGATIVE_INFINITY, + source.position.x + source.metrics.width + horizontalSpacing + ) + horizontalY = Math.max( + horizontalY ?? Number.NEGATIVE_INFINITY, + source.position.y + source.metrics.height / 2 - node.metrics.height / 2 + ) + } else { + verticalY = Math.max( + verticalY ?? Number.NEGATIVE_INFINITY, + source.position.y + source.metrics.height + verticalSpacing + ) + verticalX = Math.max( + verticalX ?? Number.NEGATIVE_INFINITY, + source.position.x + source.metrics.width / 2 - node.metrics.width / 2 + ) + } + } + + if (horizontalX !== null) { + node.position.x = horizontalX + } else if (verticalX !== null) { + node.position.x = verticalX + } + + if (verticalY !== null) { + node.position.y = verticalY + } else if (horizontalY !== null) { + node.position.y = horizontalY + } + } + } + + return incomingAxes +} + +function getEdgeAxis(edge: Edge, source: GraphNode): LayoutAxis { + if ( + edge.sourceHandle?.startsWith('condition-') || + edge.sourceHandle === 'loop-start-source' || + edge.sourceHandle === 'loop-end-source' || + edge.sourceHandle === 'parallel-start-source' || + edge.sourceHandle === 'parallel-end-source' || + source.block.type === 'condition' || + source.block.type === 'loop' || + source.block.type === 'parallel' + ) { + return 'horizontal' } - // Resolve any overlaps - resolveOverlaps(Array.from(layers.values()).flat(), direction, horizontalSpacing, verticalSpacing) + return source.block.horizontalHandles === false ? 'vertical' : 'horizontal' } function resolveOverlaps( nodes: GraphNode[], - direction: AutoLayoutDirection, + incomingAxes: Map<string, LayoutAxis | 'mixed'>, horizontalSpacing: number, verticalSpacing: number ): void { @@ -111,7 +176,7 @@ function resolveOverlaps( // Sort nodes by position for consistent processing const sortedNodes = [...nodes].sort((a, b) => { if (a.layer !== b.layer) return a.layer - b.layer - return direction === 'vertical' ? a.position.x - b.position.x : a.position.y - b.position.y + return a.position.y - b.position.y || a.position.x - b.position.x }) for (let i = 0; i < sortedNodes.length; i++) { @@ -125,34 +190,23 @@ function resolveOverlaps( // Check for overlap with margin if (boxesOverlap(box1, box2, 30)) { hasOverlap = true - - if (node1.layer === node2.layer && direction === 'vertical') { - const midpoint = (node1.position.x + node2.position.x) / 2 - - node1.position.x = midpoint - node1.metrics.width / 2 - horizontalSpacing / 2 - node2.position.x = midpoint + node2.metrics.width / 2 + horizontalSpacing / 2 - } else if (node1.layer === node2.layer) { - const totalHeight = node1.metrics.height + node2.metrics.height + verticalSpacing - const midpoint = (node1.position.y + node2.position.y) / 2 - - node1.position.y = midpoint - node1.metrics.height / 2 - verticalSpacing / 2 - node2.position.y = midpoint + node2.metrics.height / 2 + verticalSpacing / 2 - } else if (direction === 'vertical') { - const requiredSpace = box1.x + box1.width + horizontalSpacing - if (node2.position.x < requiredSpace) { - node2.position.x = requiredSpace - } + const separateHorizontally = + incomingAxes.get(node1.id) === 'vertical' && + incomingAxes.get(node2.id) === 'vertical' + + if (separateHorizontally) { + node2.position.x = Math.max( + node2.position.x, + box1.x + box1.width + horizontalSpacing + ) } else { - const requiredSpace = box1.y + box1.height + verticalSpacing - if (node2.position.y < requiredSpace) { - node2.position.y = requiredSpace - } + node2.position.y = Math.max(node2.position.y, box1.y + box1.height + verticalSpacing) } logger.debug('Resolved overlap between blocks', { block1: node1.id, block2: node2.id, - samLayer: node1.layer === node2.layer, + sameLayer: node1.layer === node2.layer, iteration, }) } diff --git a/apps/tradinggoose/lib/workflows/autolayout/targeted.ts b/apps/tradinggoose/lib/workflows/autolayout/targeted.ts deleted file mode 100644 index 36b3424c1..000000000 --- a/apps/tradinggoose/lib/workflows/autolayout/targeted.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { createLogger } from '@/lib/logs/console/logger' -import type { AutoLayoutDirection } from '@/lib/workflows/workflow-direction' -import type { BlockState } from '@/stores/workflows/workflow/types' -import { assignLayers, groupByLayer } from './layering' -import { calculatePositions } from './positioning' -import type { Edge, LayoutOptions } from './types' -import { - CONTAINER_PADDING, - CONTAINER_PADDING_X, - CONTAINER_PADDING_Y, - DEFAULT_CONTAINER_HEIGHT, - DEFAULT_CONTAINER_WIDTH, - getBlockMetrics, - getBlocksByParent, - isContainerType, - prepareBlockMetrics, - ROOT_PADDING_X, - ROOT_PADDING_Y, -} from './utils' - -const logger = createLogger('AutoLayout:Targeted') - -export interface TargetedLayoutOptions extends LayoutOptions { - changedBlockIds: string[] - verticalSpacing?: number - horizontalSpacing?: number -} - -export function applyTargetedLayout( - blocks: Record<string, BlockState>, - edges: Edge[], - options: TargetedLayoutOptions -): Record<string, BlockState> { - const { - changedBlockIds, - direction = 'horizontal', - verticalSpacing = 200, - horizontalSpacing = 550, - } = options - - if (!changedBlockIds || changedBlockIds.length === 0) { - return blocks - } - - const changedSet = new Set(changedBlockIds) - const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks)) - - const groups = getBlocksByParent(blocksCopy) - - layoutGroup( - null, - groups.root, - blocksCopy, - edges, - changedSet, - direction, - verticalSpacing, - horizontalSpacing - ) - - for (const [parentId, childIds] of groups.children.entries()) { - layoutGroup( - parentId, - childIds, - blocksCopy, - edges, - changedSet, - direction, - verticalSpacing, - horizontalSpacing - ) - } - - return blocksCopy -} - -function layoutGroup( - parentId: string | null, - childIds: string[], - blocks: Record<string, BlockState>, - edges: Edge[], - changedSet: Set<string>, - direction: AutoLayoutDirection, - verticalSpacing: number, - horizontalSpacing: number -): void { - if (childIds.length === 0) return - - const parentBlock = parentId ? blocks[parentId] : undefined - - const requestedLayout = childIds.filter((id) => { - const block = blocks[id] - if (!block) return false - // Never reposition containers, only update their dimensions - if (isContainerType(block.type)) return false - return changedSet.has(id) - }) - const missingPositions = childIds.filter((id) => { - const block = blocks[id] - if (!block) return false - // Containers with missing positions should still get positioned - return !hasPosition(block) - }) - const needsLayoutSet = new Set([...requestedLayout, ...missingPositions]) - const needsLayout = Array.from(needsLayoutSet) - - if (parentBlock) { - updateContainerDimensions(parentBlock, childIds, blocks) - } - - // Always update container dimensions even if no blocks need repositioning - // This ensures containers resize properly when children are added/removed - if (needsLayout.length === 0) { - return - } - - const oldPositions = new Map<string, { x: number; y: number }>() - - for (const id of childIds) { - const block = blocks[id] - if (!block) continue - oldPositions.set(id, { ...block.position }) - } - - const layoutPositions = computeLayoutPositions( - childIds, - blocks, - edges, - parentBlock, - direction, - horizontalSpacing, - verticalSpacing - ) - - if (layoutPositions.size === 0) { - // No layout positions computed, but still update container dimensions - if (parentBlock) { - updateContainerDimensions(parentBlock, childIds, blocks) - } - return - } - - let offsetX = 0 - let offsetY = 0 - - const anchorId = childIds.find((id) => !needsLayout.includes(id) && layoutPositions.has(id)) - - if (anchorId) { - const oldPos = oldPositions.get(anchorId) - const newPos = layoutPositions.get(anchorId) - if (oldPos && newPos) { - offsetX = oldPos.x - newPos.x - offsetY = oldPos.y - newPos.y - } - } else { - // No anchor - positions from calculatePositions are already correct relative to padding - // Container positions are parent-relative, root positions are absolute - // The normalization in computeLayoutPositions already handled the padding offset - offsetX = 0 - offsetY = 0 - } - - for (const id of needsLayout) { - const block = blocks[id] - const newPos = layoutPositions.get(id) - if (!block || !newPos) continue - block.position = { - x: newPos.x + offsetX, - y: newPos.y + offsetY, - } - } -} - -function computeLayoutPositions( - childIds: string[], - blocks: Record<string, BlockState>, - edges: Edge[], - parentBlock: BlockState | undefined, - direction: AutoLayoutDirection, - horizontalSpacing: number, - verticalSpacing: number -): Map<string, { x: number; y: number }> { - const subsetBlocks: Record<string, BlockState> = {} - for (const id of childIds) { - subsetBlocks[id] = blocks[id] - } - - const subsetEdges = edges.filter( - (edge) => childIds.includes(edge.source) && childIds.includes(edge.target) - ) - - if (Object.keys(subsetBlocks).length === 0) { - return new Map() - } - - const nodes = assignLayers(subsetBlocks, subsetEdges) - prepareBlockMetrics(nodes) - - const layoutOptions: LayoutOptions = parentBlock - ? { - direction, - horizontalSpacing: horizontalSpacing * 0.85, - verticalSpacing, - padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y }, - alignment: 'center', - } - : { - direction, - horizontalSpacing, - verticalSpacing, - padding: { x: ROOT_PADDING_X, y: ROOT_PADDING_Y }, - alignment: 'center', - } - - calculatePositions(groupByLayer(nodes), layoutOptions) - - // Now normalize positions to start from 0,0 relative to the container/root - let minX = Number.POSITIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const node of nodes.values()) { - minX = Math.min(minX, node.position.x) - minY = Math.min(minY, node.position.y) - maxX = Math.max(maxX, node.position.x + node.metrics.width) - maxY = Math.max(maxY, node.position.y + node.metrics.height) - } - - // Adjust all positions to be relative to the padding offset - const xOffset = (parentBlock ? CONTAINER_PADDING_X : ROOT_PADDING_X) - minX - const yOffset = (parentBlock ? CONTAINER_PADDING_Y : ROOT_PADDING_Y) - minY - - const positions = new Map<string, { x: number; y: number }>() - for (const node of nodes.values()) { - positions.set(node.id, { - x: node.position.x + xOffset, - y: node.position.y + yOffset, - }) - } - - if (parentBlock) { - const calculatedWidth = maxX - minX + CONTAINER_PADDING * 2 - const calculatedHeight = maxY - minY + CONTAINER_PADDING * 2 - - parentBlock.data = { - ...parentBlock.data, - width: Math.max(calculatedWidth, DEFAULT_CONTAINER_WIDTH), - height: Math.max(calculatedHeight, DEFAULT_CONTAINER_HEIGHT), - } - } - - return positions -} - -function updateContainerDimensions( - parentBlock: BlockState, - childIds: string[], - blocks: Record<string, BlockState> -): void { - if (childIds.length === 0) { - // No children - use minimum dimensions - parentBlock.data = { - ...parentBlock.data, - width: DEFAULT_CONTAINER_WIDTH, - height: DEFAULT_CONTAINER_HEIGHT, - } - parentBlock.layout = { - ...parentBlock.layout, - measuredWidth: DEFAULT_CONTAINER_WIDTH, - measuredHeight: DEFAULT_CONTAINER_HEIGHT, - } - return - } - - let minX = Number.POSITIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const id of childIds) { - const child = blocks[id] - if (!child) continue - const metrics = getBlockMetrics(child) - - minX = Math.min(minX, child.position.x) - minY = Math.min(minY, child.position.y) - maxX = Math.max(maxX, child.position.x + metrics.width) - maxY = Math.max(maxY, child.position.y + metrics.height) - } - - if (!Number.isFinite(minX) || !Number.isFinite(minY)) { - return - } - - // Match the regular autolayout's dimension calculation - const calculatedWidth = maxX - minX + CONTAINER_PADDING * 2 - const calculatedHeight = maxY - minY + CONTAINER_PADDING * 2 - - parentBlock.data = { - ...parentBlock.data, - width: Math.max(calculatedWidth, DEFAULT_CONTAINER_WIDTH), - height: Math.max(calculatedHeight, DEFAULT_CONTAINER_HEIGHT), - } - - parentBlock.layout = { - ...parentBlock.layout, - measuredWidth: parentBlock.data.width, - measuredHeight: parentBlock.data.height, - } -} - -function hasPosition(block: BlockState): boolean { - if (!block.position) return false - const { x, y } = block.position - return Number.isFinite(x) && Number.isFinite(y) -} diff --git a/apps/tradinggoose/lib/workflows/autolayout/types.ts b/apps/tradinggoose/lib/workflows/autolayout/types.ts index 7d2106f73..be91caa49 100644 --- a/apps/tradinggoose/lib/workflows/autolayout/types.ts +++ b/apps/tradinggoose/lib/workflows/autolayout/types.ts @@ -1,8 +1,6 @@ -import type { AutoLayoutDirection } from '@/lib/workflows/workflow-direction' import type { BlockState, Position } from '@/stores/workflows/workflow/types' export interface LayoutOptions { - direction?: AutoLayoutDirection horizontalSpacing?: number verticalSpacing?: number padding?: { x: number; y: number } @@ -23,22 +21,6 @@ export interface Edge { targetHandle?: string | null } -export interface Loop { - id: string - nodes: string[] - iterations: number - loopType: 'for' | 'forEach' | 'while' | 'doWhile' - forEachItems?: any[] | Record<string, any> | string // Items or expression - whileCondition?: string // JS expression that evaluates to boolean -} - -export interface Parallel { - id: string - nodes: string[] - count?: number - parallelType?: 'count' | 'collection' -} - export interface BlockMetrics { width: number height: number @@ -57,11 +39,6 @@ export interface BoundingBox { height: number } -export interface LayerInfo { - layer: number - order: number -} - export interface GraphNode { id: string block: BlockState @@ -71,8 +48,3 @@ export interface GraphNode { layer: number position: Position } - -export interface AdjustmentOptions extends LayoutOptions { - preservePositions?: boolean - minimalShift?: boolean -} diff --git a/apps/tradinggoose/lib/workflows/block-availability.ts b/apps/tradinggoose/lib/workflows/block-availability.ts index 5584cccb3..c71f8db23 100644 --- a/apps/tradinggoose/lib/workflows/block-availability.ts +++ b/apps/tradinggoose/lib/workflows/block-availability.ts @@ -1,5 +1,5 @@ -import type { BlockConfig } from '@/blocks/types' import { getOAuthProviderSubjectId } from '@/lib/oauth/oauth' +import type { BlockConfig } from '@/blocks/types' export type ProviderAvailability = Record<string, boolean> @@ -34,26 +34,50 @@ const isNonOAuthCredentialInput = (block: BlockConfig['subBlocks'][number]) => { return NON_OAUTH_CREDENTIAL_HINTS.some((hint) => id.includes(hint.toLowerCase())) } +const getOAuthProviderSubjectIds = (subBlock: BlockConfig['subBlocks'][number]) => { + const serviceIds = Array.isArray(subBlock.serviceIds) + ? subBlock.serviceIds.map((serviceId) => serviceId.trim()).filter(Boolean) + : [] + + if (serviceIds.length > 0) { + return Array.from( + new Set( + serviceIds + .map((serviceId) => getOAuthProviderSubjectId({ serviceId })) + .filter((providerId): providerId is string => Boolean(providerId)) + ) + ) + } + + const providerId = getOAuthProviderSubjectId({ + provider: subBlock.provider, + serviceId: subBlock.serviceId, + requiredScopes: subBlock.requiredScopes, + }) + + return providerId ? [providerId] : [] +} + export const getBlockOAuthRequirements = (block: BlockConfig) => { const requiredOauthInputs = block.subBlocks.filter(isRequiredOAuthInput) const unconditionalProviders = new Set<string>() const conditionalProviders = new Set<string>() + const unconditionalProviderGroups: string[][] = [] + const conditionalProviderGroups: string[][] = [] const oauthConditionFields = new Set<string>() for (const subBlock of requiredOauthInputs) { - const providerId = getOAuthProviderSubjectId({ - provider: subBlock.provider, - serviceId: subBlock.serviceId, - requiredScopes: subBlock.requiredScopes, - }) - if (!providerId) continue + const providerIds = getOAuthProviderSubjectIds(subBlock) + if (providerIds.length === 0) continue const conditionField = getConditionField(subBlock.condition) if (conditionField) { - conditionalProviders.add(providerId) + providerIds.forEach((providerId) => conditionalProviders.add(providerId)) + conditionalProviderGroups.push(providerIds) oauthConditionFields.add(conditionField) } else { - unconditionalProviders.add(providerId) + providerIds.forEach((providerId) => unconditionalProviders.add(providerId)) + unconditionalProviderGroups.push(providerIds) } } @@ -74,6 +98,8 @@ export const getBlockOAuthRequirements = (block: BlockConfig) => { return { unconditionalProviders: Array.from(unconditionalProviders), conditionalProviders: Array.from(conditionalProviders), + unconditionalProviderGroups, + conditionalProviderGroups, hasNonOAuthAlternative, } } @@ -83,17 +109,27 @@ const isProviderAvailable = (providerId: string, availability: ProviderAvailabil } export const isBlockAvailable = (block: BlockConfig, availability: ProviderAvailability) => { - const { unconditionalProviders, conditionalProviders, hasNonOAuthAlternative } = - getBlockOAuthRequirements(block) + const { + unconditionalProviders, + conditionalProviders, + unconditionalProviderGroups, + conditionalProviderGroups, + hasNonOAuthAlternative, + } = getBlockOAuthRequirements(block) - if (unconditionalProviders.length > 0) { + if (unconditionalProviderGroups.length > 0) { + const allUnconditionalAvailable = unconditionalProviderGroups.every((providerIds) => + providerIds.some((providerId) => isProviderAvailable(providerId, availability)) + ) + if (!allUnconditionalAvailable) return false + } else if (unconditionalProviders.length > 0) { const allUnconditionalAvailable = unconditionalProviders.every((providerId) => isProviderAvailable(providerId, availability) ) if (!allUnconditionalAvailable) return false } - if (conditionalProviders.length === 0) { + if (conditionalProviderGroups.length === 0 && conditionalProviders.length === 0) { return true } @@ -101,6 +137,12 @@ export const isBlockAvailable = (block: BlockConfig, availability: ProviderAvail return true } + if (conditionalProviderGroups.length > 0) { + return conditionalProviderGroups.some((providerIds) => + providerIds.some((providerId) => isProviderAvailable(providerId, availability)) + ) + } + return conditionalProviders.some((providerId) => isProviderAvailable(providerId, availability)) } diff --git a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts index b4ece72b0..787698d97 100644 --- a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts +++ b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts @@ -6,6 +6,7 @@ import { serializeWorkflowToTgMermaid, TG_MERMAID_DOCUMENT_FORMAT, } from '@/lib/workflows/studio-workflow-mermaid' +import { applyAutoLayout } from '@/lib/workflows/autolayout' describe('studio workflow Mermaid documents', () => { const workflowState: WorkflowSnapshot = { @@ -492,6 +493,52 @@ inputTrigger --> agentBlock expect(document).toContain('%% TG_WORKFLOW {"direction":"LR"') }) + it('keeps auto-layout lanes from each source handle orientation', () => { + const agent = ( + id: string, + x: number, + y: number, + horizontalHandles = true + ): WorkflowSnapshot['blocks'][string] => ({ + id, + type: 'agent', + name: id, + position: { x, y }, + subBlocks: {}, + outputs: {}, + enabled: true, + horizontalHandles, + }) + + const result = applyAutoLayout( + { + start: agent('start', 0, 0), + branchA: agent('branchA', 0, 0), + branchA2: agent('branchA2', 0, 0), + branchB: agent('branchB', 0, 0), + verticalA: agent('verticalA', 0, 0, false), + verticalB: agent('verticalB', 0, 0, false), + }, + [ + { id: 'start-a', source: 'start', target: 'branchA' }, + { id: 'start-b', source: 'start', target: 'branchB' }, + { id: 'a-a2', source: 'branchA', target: 'branchA2' }, + { id: 'vertical-a-b', source: 'verticalA', target: 'verticalB' }, + ] + ) + + expect(result.success).toBe(true) + const blocks = result.blocks + const centerY = (id: string) => blocks[id].position.y + 50 + const centerX = (id: string) => blocks[id].position.x + 175 + + expect(centerY('branchA2')).toBe(centerY('branchA')) + expect(centerY('branchB')).toBeGreaterThan(centerY('branchA')) + expect(blocks.branchA2.position.x).toBeGreaterThan(blocks.branchA.position.x) + expect(centerX('verticalB')).toBe(centerX('verticalA')) + expect(blocks.verticalB.position.y).toBeGreaterThan(blocks.verticalA.position.y) + }) + it('reports missing raw-id visible edge lines using the document naming style', () => { const invalidDocument = `flowchart TD %% TG_WORKFLOW {"direction":"TD","isDeployed":false,"lastSaved":1776131914844,"version":"tg-mermaid-v1"} diff --git a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts index a3900b76b..8b6b57fcc 100644 --- a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts +++ b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts @@ -1,8 +1,14 @@ import type { Edge } from '@xyflow/react' import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { stableStringifyJsonValue } from '@/lib/json/stable' import { TG_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' import { inferMermaidDirectionFromWorkflowState } from '@/lib/workflows/workflow-direction' -import type { BlockState, Loop, Parallel, WorkflowDirection } from '@/stores/workflows/workflow/types' +import type { + BlockState, + Loop, + Parallel, + WorkflowDirection, +} from '@/stores/workflows/workflow/types' export { TG_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' @@ -63,25 +69,8 @@ const TG_LOOP_PREFIX = `${COMMENT_PREFIX}TG_LOOP ` const TG_PARALLEL_PREFIX = `${COMMENT_PREFIX}TG_PARALLEL ` const CONDITION_INPUT_KEY = 'conditions' -function sortJsonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(sortJsonValue) - } - - if (value && typeof value === 'object') { - return Object.keys(value as Record<string, unknown>) - .sort() - .reduce<Record<string, unknown>>((sorted, key) => { - sorted[key] = sortJsonValue((value as Record<string, unknown>)[key]) - return sorted - }, {}) - } - - return value -} - function toDocumentJson(value: unknown): string { - return JSON.stringify(sortJsonValue(value)) + return stableStringifyJsonValue(value) } function toCommentLine(prefix: string, value: unknown): string { @@ -93,7 +82,10 @@ function escapeMermaidLabel(value: string): string { } function unescapeMermaidLabel(value: string): string { - return value.replace(/<br\/>/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\') + return value + .replace(/<br\/>/g, '\n') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\') } function buildAliasMap(blockIds: string[]): Map<string, string> { @@ -108,9 +100,7 @@ function resolveBlockIdFromVisibleNodeId( return aliasToBlockId.get(nodeId) ?? (knownBlockIds.has(nodeId) ? nodeId : undefined) } -function parseRectNodeLine( - line: string -): { nodeId: string; label: string } | null { +function parseRectNodeLine(line: string): { nodeId: string; label: string } | null { const rectMatch = line.match(/^([A-Za-z0-9_]+)(?:\(\["(.*)"\]\)|\["(.*)"\])$/) const label = rectMatch?.[2] ?? rectMatch?.[3] @@ -150,9 +140,7 @@ function parseConditionEntries(value: unknown): ConditionEntry[] { const trimmed = rawKey.trim() if (trimmed === 'else if') { nextElseIfIndexRef.current += 1 - return nextElseIfIndexRef.current === 1 - ? 'else-if' - : `else-if-${nextElseIfIndexRef.current}` + return nextElseIfIndexRef.current === 1 ? 'else-if' : `else-if-${nextElseIfIndexRef.current}` } return trimmed } @@ -304,7 +292,9 @@ function buildBlockLabelLines(blockId: string, block: BlockState): string[] { ? parseConditionEntries(block.subBlocks?.[CONDITION_INPUT_KEY]?.value) : [] - const subBlockKeys = Object.keys(block.subBlocks || {}).sort((left, right) => left.localeCompare(right)) + const subBlockKeys = Object.keys(block.subBlocks || {}).sort((left, right) => + left.localeCompare(right) + ) for (const subBlockKey of subBlockKeys) { if (subBlockKey === CONDITION_INPUT_KEY && conditionEntries.length > 0) { continue @@ -319,7 +309,10 @@ function buildBlockLabelLines(blockId: string, block: BlockState): string[] { } const dataEntries = Object.entries(block.data || {}) - .filter(([key, value]) => value !== undefined && !['parentId', 'extent', 'width', 'height', 'type'].includes(key)) + .filter( + ([key, value]) => + value !== undefined && !['parentId', 'extent', 'width', 'height', 'type'].includes(key) + ) .sort(([left], [right]) => left.localeCompare(right)) for (const [key, value] of dataEntries) { lines.push(`data.${key}: ${serializeLabelValue(value)}`) @@ -340,7 +333,11 @@ function renderDiamondNode(nodeId: string, labelLines: string[], indent: string) return `${indent}${nodeId}{"${escapeMermaidLabel(labelLines.join('\n'))}"}` } -function createContainerNodeId(alias: string, type: 'loop' | 'parallel', kind: 'start' | 'end'): string { +function createContainerNodeId( + alias: string, + type: 'loop' | 'parallel', + kind: 'start' | 'end' +): string { return `${alias}__${type}_${kind}` } @@ -403,11 +400,7 @@ function emitBlockGraphLines(params: { for (const entry of conditionEntries) { const branchNodeId = createConditionBranchNodeId(alias, entry.key) lines.push( - renderRectNode( - branchNodeId, - buildConditionBranchLabelLines(blockId, entry), - `${indent} ` - ) + renderRectNode(branchNodeId, buildConditionBranchLabelLines(blockId, entry), `${indent} `) ) lines.push(`${indent} ${alias} --> ${branchNodeId}`) } @@ -449,7 +442,10 @@ function emitBlockGraphLines(params: { lines.push(`${indent}end`) } -function extractConditionDisplayKey(blockId: string, sourceHandle: string | null | undefined): string | null { +function extractConditionDisplayKey( + blockId: string, + sourceHandle: string | null | undefined +): string | null { if (!sourceHandle || !sourceHandle.startsWith('condition-')) { return null } @@ -1063,10 +1059,7 @@ function isContainerBlockType(blockType: string | undefined): blockType is 'loop return blockType === 'loop' || blockType === 'parallel' } -function getContainerAncestorChain( - blockId: string, - blocks: Record<string, BlockState> -): string[] { +function getContainerAncestorChain(blockId: string, blocks: Record<string, BlockState>): string[] { const chain: string[] = [] const visited = new Set<string>() let currentParentId = blocks[blockId]?.data?.parentId @@ -1141,7 +1134,10 @@ function getEdgeSourceContext( ): string[] { const context = getContainerAncestorChain(edge.source, blocks) - if (isContainerStartSourceHandle(edge.sourceHandle) && isContainerBlockType(blocks[edge.source]?.type)) { + if ( + isContainerStartSourceHandle(edge.sourceHandle) && + isContainerBlockType(blocks[edge.source]?.type) + ) { context.push(edge.source) } @@ -1154,7 +1150,10 @@ function getEdgeTargetContext( ): string[] { const context = getContainerAncestorChain(edge.target, blocks) - if (isContainerEndTargetHandle(edge.targetHandle) && isContainerBlockType(blocks[edge.target]?.type)) { + if ( + isContainerEndTargetHandle(edge.targetHandle) && + isContainerBlockType(blocks[edge.target]?.type) + ) { context.push(edge.target) } @@ -1167,8 +1166,12 @@ function toNormalizedEdge( return { source: edge.source, target: edge.target, - ...(edge.sourceHandle && edge.sourceHandle !== 'source' ? { sourceHandle: edge.sourceHandle } : {}), - ...(edge.targetHandle && edge.targetHandle !== 'target' ? { targetHandle: edge.targetHandle } : {}), + ...(edge.sourceHandle && edge.sourceHandle !== 'source' + ? { sourceHandle: edge.sourceHandle } + : {}), + ...(edge.targetHandle && edge.targetHandle !== 'target' + ? { targetHandle: edge.targetHandle } + : {}), } } @@ -1374,7 +1377,9 @@ function assertVisibleEdgesMatchCanonical( detailParts.push(`missing TG_EDGE entries for ${missingCanonical.slice(0, 3).join(', ')}`) } if (missingVisible.length > 0) { - detailParts.push(`missing visible connection lines for ${missingVisible.slice(0, 3).join(', ')}`) + detailParts.push( + `missing visible connection lines for ${missingVisible.slice(0, 3).join(', ')}` + ) const expectedVisibleLines = missingVisible .map((key) => canonicalEdgeByKey.get(key)) .filter((edge): edge is Edge => !!edge) @@ -1407,7 +1412,8 @@ function mergeOverlayIntoBlock( nextSubBlocks[subBlockId] = { id: subBlockId, type: - existingSubBlock?.type ?? (subBlockId === CONDITION_INPUT_KEY ? 'condition-input' : 'short-input'), + existingSubBlock?.type ?? + (subBlockId === CONDITION_INPUT_KEY ? 'condition-input' : 'short-input'), value: value as any, } } @@ -1451,7 +1457,9 @@ function mergeConditionEntriesIntoBlock( existingBlock: BlockState, entries: ConditionEntry[] ): BlockState { - const existingEntries = parseConditionEntries(existingBlock.subBlocks?.[CONDITION_INPUT_KEY]?.value) + const existingEntries = parseConditionEntries( + existingBlock.subBlocks?.[CONDITION_INPUT_KEY]?.value + ) const existingSignature = toDocumentJson(existingEntries) const nextSignature = toDocumentJson(parseConditionEntries(entries)) @@ -1500,16 +1508,22 @@ function assertBlockState(value: unknown): asserts value is BlockState { typeof candidate.position.x !== 'number' || typeof candidate.position.y !== 'number' ) { - throw new Error( - 'Invalid TG_BLOCK payload: expected position with numeric x and y values.' - ) + throw new Error('Invalid TG_BLOCK payload: expected position with numeric x and y values.') } - if (!candidate.subBlocks || typeof candidate.subBlocks !== 'object' || Array.isArray(candidate.subBlocks)) { + if ( + !candidate.subBlocks || + typeof candidate.subBlocks !== 'object' || + Array.isArray(candidate.subBlocks) + ) { throw new Error('Invalid TG_BLOCK payload: expected subBlocks object.') } - if (!candidate.outputs || typeof candidate.outputs !== 'object' || Array.isArray(candidate.outputs)) { + if ( + !candidate.outputs || + typeof candidate.outputs !== 'object' || + Array.isArray(candidate.outputs) + ) { throw new Error('Invalid TG_BLOCK payload: expected outputs object.') } @@ -1598,7 +1612,9 @@ export function serializeWorkflowToTgMermaid( lines.push(toCommentLine(TG_EDGE_PREFIX, edge)) } - const loopIds = Object.keys(workflowState.loops ?? {}).sort((left, right) => left.localeCompare(right)) + const loopIds = Object.keys(workflowState.loops ?? {}).sort((left, right) => + left.localeCompare(right) + ) for (const loopId of loopIds) { lines.push(toCommentLine(TG_LOOP_PREFIX, workflowState.loops[loopId])) } @@ -1697,7 +1713,10 @@ export function parseTgMermaidToWorkflow( visibleGraph.visibleBlockIds, visibleGraph.inferredParentIds ) - const normalizedVisibleEdges = normalizeLogicalWorkflowEdges(visibleGraph.edges, blocksWithVisibleParenting) + const normalizedVisibleEdges = normalizeLogicalWorkflowEdges( + visibleGraph.edges, + blocksWithVisibleParenting + ) const normalizedCanonicalEdges = normalizeLogicalWorkflowEdges(edges, blocksWithVisibleParenting) const syncedContainers = syncContainerNodeMembership(blocksWithVisibleParenting, loops, parallels) @@ -1741,8 +1760,7 @@ export function buildWorkflowDocumentPreviewDiff( const updated = [...nextBlockIds] .filter((blockId) => currentBlockIds.has(blockId)) .filter( - (blockId) => - toDocumentJson(currentBlocks[blockId]) !== toDocumentJson(nextBlocks[blockId]) + (blockId) => toDocumentJson(currentBlocks[blockId]) !== toDocumentJson(nextBlocks[blockId]) ) .sort() diff --git a/apps/tradinggoose/lib/workflows/workflow-direction.ts b/apps/tradinggoose/lib/workflows/workflow-direction.ts index e094e79a6..77b3858de 100644 --- a/apps/tradinggoose/lib/workflows/workflow-direction.ts +++ b/apps/tradinggoose/lib/workflows/workflow-direction.ts @@ -2,8 +2,6 @@ import { applyAutoLayout } from '@/lib/workflows/autolayout' import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' import type { BlockState, WorkflowDirection } from '@/stores/workflows/workflow/types' -export type AutoLayoutDirection = 'horizontal' | 'vertical' - type WorkflowGraphState = Pick<WorkflowSnapshot, 'blocks' | 'edges'> function getAbsoluteBlockPosition( @@ -89,21 +87,6 @@ export function inferMermaidDirectionFromWorkflowState( return horizontalSpread > verticalSpread ? 'LR' : 'TD' } -function toAutoLayoutDirection(direction: WorkflowDirection): AutoLayoutDirection { - return direction === 'LR' ? 'horizontal' : 'vertical' -} - -export function resolveAutoLayoutDirection( - workflowState: WorkflowGraphState, - requestedDirection?: AutoLayoutDirection | 'auto' -): AutoLayoutDirection { - if (requestedDirection && requestedDirection !== 'auto') { - return requestedDirection - } - - return toAutoLayoutDirection(inferMermaidDirectionFromWorkflowState(workflowState)) -} - export function normalizeWorkflowStateToMermaidDirection( workflowState: WorkflowSnapshot, direction: WorkflowDirection @@ -123,15 +106,7 @@ export function normalizeWorkflowStateToMermaidDirection( } } - const relayoutResult = applyAutoLayout( - workflowState.blocks, - workflowState.edges, - workflowState.loops, - workflowState.parallels, - { - direction: toAutoLayoutDirection(direction), - } - ) + const relayoutResult = applyAutoLayout(workflowState.blocks, workflowState.edges) if (!relayoutResult.success || !relayoutResult.blocks) { throw new Error(relayoutResult.error || 'Failed to re-layout workflow for Mermaid direction') diff --git a/apps/tradinggoose/next.config.ts b/apps/tradinggoose/next.config.ts index a8aaeb1cf..bebb6ff15 100644 --- a/apps/tradinggoose/next.config.ts +++ b/apps/tradinggoose/next.config.ts @@ -1,3 +1,4 @@ +import createNextIntlPlugin from 'next-intl/plugin' import type { NextConfig } from 'next' import { isDev, isHosted } from '@/lib/environment' import { env, isTruthy } from './lib/env' @@ -271,4 +272,6 @@ const nextConfig: NextConfig = { }, } -export default nextConfig +const withNextIntl = createNextIntlPlugin('./i18n/request.ts') + +export default withNextIntl(nextConfig) diff --git a/apps/tradinggoose/package.json b/apps/tradinggoose/package.json index d1cc8627d..a15bb59dc 100644 --- a/apps/tradinggoose/package.json +++ b/apps/tradinggoose/package.json @@ -13,6 +13,8 @@ "dev:full": "concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"", "build": "NODE_OPTIONS='--max-old-space-size=8192' next build", "start": "next start", + "i18n": "bunx lingo.dev@latest run", + "i18n:frozen": "bunx lingo.dev@latest run --frozen", "prepare": "cd ../.. && bun husky", "test": "vitest run", "test:watch": "vitest", @@ -113,6 +115,7 @@ "mysql2": "3.14.3", "nanoid": "3.3.11", "next": "16.2.2", + "next-intl": "4.9.1", "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", "node-fetch": "3.3.2", @@ -132,6 +135,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "3.0.6", "react-use": "17.6.0", + "recharts": "3.8.1", "remark-gfm": "4.0.1", "resend": "6.10.0", "sharp": "0.34.3", diff --git a/apps/tradinggoose/providers/market/alpha-vantage/config.ts b/apps/tradinggoose/providers/market/alpha-vantage/config.ts index 4aaee4d5c..b9fa3a5a2 100644 --- a/apps/tradinggoose/providers/market/alpha-vantage/config.ts +++ b/apps/tradinggoose/providers/market/alpha-vantage/config.ts @@ -67,6 +67,12 @@ export const alphaVantageProviderConfig: MarketProviderConfig = { }, }, }, + live: { + supportsPolling: true, + channels: ['quote-snapshots'], + supportsInterval: false, + pollingIntervalMs: 60_000, + }, }, rulePrecedence: { default: ['market', 'currency', 'assetClass', 'country', 'city', 'listing'], diff --git a/apps/tradinggoose/providers/market/market-hours/market-hours-api.ts b/apps/tradinggoose/providers/market/market-hours/market-hours-api.ts index b15417761..ba4126ffa 100644 --- a/apps/tradinggoose/providers/market/market-hours/market-hours-api.ts +++ b/apps/tradinggoose/providers/market/market-hours/market-hours-api.ts @@ -4,13 +4,6 @@ import { DATE_KEY_RE } from './constants' import { toDateKey, toDateKeyValue } from './date-utils' import type { MarketHoursResponse } from './types' -const marketHoursCache = new Map<string, MarketHoursResponse | null>() -const marketHoursInFlight = new Map<string, Promise<MarketHoursResponse | null>>() -const marketHoursRangeInFlight = new Map< - string, - Promise<Map<string, MarketHoursResponse> | null> ->() - const extractMarketHoursResponse = (payload: unknown): MarketHoursResponse | null => { if (!payload || typeof payload !== 'object') return null if ('error' in payload && (payload as { error?: unknown }).error) return null @@ -43,44 +36,17 @@ export const resolveMarketHours = async ( date: Date ): Promise<MarketHoursResponse | null> => { const dateKey = toDateKey(date) - const cacheKey = `${listingId}:${listingType}:${dateKey}` - if (marketHoursCache.has(cacheKey)) { - return marketHoursCache.get(cacheKey) ?? null - } - const pending = marketHoursInFlight.get(cacheKey) - if (pending) { - return pending - } - - const fetchPromise = (async () => { - const params = new URLSearchParams({ - listing_id: listingId, - listingType, - date: dateKey, - version: MARKET_API_VERSION, - }) - const response = await marketClient.makeRequest<MarketHoursResponse>( - `/api/get/market-hours?${params.toString()}` - ) - if (!response.success) { - marketHoursCache.set(cacheKey, null) - return null - } - const normalized = extractMarketHoursResponse(response.data) - if (!normalized) { - marketHoursCache.set(cacheKey, null) - return null - } - marketHoursCache.set(cacheKey, normalized) - return normalized - })() - - marketHoursInFlight.set(cacheKey, fetchPromise) - try { - return await fetchPromise - } finally { - marketHoursInFlight.delete(cacheKey) - } + const params = new URLSearchParams({ + listing_id: listingId, + listingType, + date: dateKey, + version: MARKET_API_VERSION, + }) + const response = await marketClient.makeRequest<MarketHoursResponse>( + `/api/get/market-hours?${params.toString()}` + ) + if (!response.success) return null + return extractMarketHoursResponse(response.data) } export const resolveMarketHoursRange = async ( @@ -89,46 +55,55 @@ export const resolveMarketHoursRange = async ( startDate: Date, endDate: Date ): Promise<Map<string, MarketHoursResponse> | null> => { - const rangeKey = `${listingId}:${listingType}:${toDateKey(startDate)}:${toDateKey(endDate)}` - const pending = marketHoursRangeInFlight.get(rangeKey) - if (pending) { - return pending - } - - const fetchPromise = (async () => { - const params = new URLSearchParams({ - listing_id: listingId, - listingType, - startDate: toDateKey(startDate), - endDate: toDateKey(endDate), - version: MARKET_API_VERSION, - }) - const response = await marketClient.makeRequest<unknown>( - `/api/get/market-hours?${params.toString()}` - ) - if (!response.success) return null + const params = new URLSearchParams({ + listing_id: listingId, + listingType, + startDate: toDateKey(startDate), + endDate: toDateKey(endDate), + version: MARKET_API_VERSION, + }) + const response = await marketClient.makeRequest<unknown>( + `/api/get/market-hours?${params.toString()}` + ) + if (!response.success) return null + + const payload = response.data + if (!payload || typeof payload !== 'object') return null + if ('error' in payload && (payload as { error?: unknown }).error) return null - const payload = response.data - if (!payload || typeof payload !== 'object') return null - if ('error' in payload && (payload as { error?: unknown }).error) return null + const root = + 'data' in payload && + (payload as { data?: unknown }).data && + typeof (payload as { data?: unknown }).data === 'object' + ? (payload as { data?: unknown }).data + : payload - const root = - 'data' in payload && - (payload as { data?: unknown }).data && - typeof (payload as { data?: unknown }).data === 'object' - ? (payload as { data?: unknown }).data - : payload + const entries: Array<[string, MarketHoursResponse]> = [] + const pushEntry = (dateKey: string | null, value: unknown) => { + if (!dateKey) return + const normalized = extractMarketHoursResponse(value) + if (!normalized) return + entries.push([dateKey, normalized]) + } - const entries: Array<[string, MarketHoursResponse]> = [] - const pushEntry = (dateKey: string | null, value: unknown) => { - if (!dateKey) return - const normalized = extractMarketHoursResponse(value) - if (!normalized) return - entries.push([dateKey, normalized]) + if (Array.isArray(root)) { + for (const item of root) { + if (!item || typeof item !== 'object') continue + const dateKey = toDateKeyValue( + (item as { date?: unknown }).date ?? + (item as { day?: unknown }).day ?? + (item as { sessionDate?: unknown }).sessionDate ?? + (item as { marketDate?: unknown }).marketDate + ) + pushEntry(dateKey, item) } - - if (Array.isArray(root)) { - for (const item of root) { + } else if (root && typeof root === 'object') { + const possibleList = + (root as { days?: unknown }).days ?? + (root as { items?: unknown }).items ?? + (root as { marketDays?: unknown }).marketDays + if (Array.isArray(possibleList)) { + for (const item of possibleList) { if (!item || typeof item !== 'object') continue const dateKey = toDateKeyValue( (item as { date?: unknown }).date ?? @@ -138,43 +113,14 @@ export const resolveMarketHoursRange = async ( ) pushEntry(dateKey, item) } - } else if (root && typeof root === 'object') { - const possibleList = - (root as { days?: unknown }).days ?? - (root as { items?: unknown }).items ?? - (root as { marketDays?: unknown }).marketDays - if (Array.isArray(possibleList)) { - for (const item of possibleList) { - if (!item || typeof item !== 'object') continue - const dateKey = toDateKeyValue( - (item as { date?: unknown }).date ?? - (item as { day?: unknown }).day ?? - (item as { sessionDate?: unknown }).sessionDate ?? - (item as { marketDate?: unknown }).marketDate - ) - pushEntry(dateKey, item) - } - } else { - for (const [key, value] of Object.entries(root)) { - if (!DATE_KEY_RE.test(key)) continue - pushEntry(key, value) - } + } else { + for (const [key, value] of Object.entries(root)) { + if (!DATE_KEY_RE.test(key)) continue + pushEntry(key, value) } } - - if (!entries.length) return new Map() - const result = new Map<string, MarketHoursResponse>() - for (const [key, value] of entries) { - result.set(key, value) - marketHoursCache.set(`${listingId}:${listingType}:${key}`, value) - } - return result - })() - - marketHoursRangeInFlight.set(rangeKey, fetchPromise) - try { - return await fetchPromise - } finally { - marketHoursRangeInFlight.delete(rangeKey) } + + if (!entries.length) return new Map() + return new Map(entries) } diff --git a/apps/tradinggoose/providers/market/providers.ts b/apps/tradinggoose/providers/market/providers.ts index 9a2d86eac..86c8a2e5e 100644 --- a/apps/tradinggoose/providers/market/providers.ts +++ b/apps/tradinggoose/providers/market/providers.ts @@ -52,9 +52,11 @@ export interface MarketSeriesRetentionPolicy { export interface MarketLiveInputCapabilities { supportsStreaming?: boolean + supportsPolling?: boolean channels?: string[] supportsInterval?: boolean intervals?: string[] + pollingIntervalMs?: number } export interface MarketProviderCapabilities { diff --git a/apps/tradinggoose/providers/market/yahoo-finance/config.ts b/apps/tradinggoose/providers/market/yahoo-finance/config.ts index 8e95f54ae..bb878eceb 100644 --- a/apps/tradinggoose/providers/market/yahoo-finance/config.ts +++ b/apps/tradinggoose/providers/market/yahoo-finance/config.ts @@ -374,6 +374,12 @@ export const YahooFinanceProviderConfig: MarketProviderConfig = { }, }, }, + live: { + supportsPolling: true, + channels: ['quote-snapshots'], + supportsInterval: false, + pollingIntervalMs: 15_000, + }, }, rulePrecedence: { default: ['market', 'currency', 'assetClass', 'country', 'city', 'listing'], diff --git a/apps/tradinggoose/providers/trading/alpaca/accounts.ts b/apps/tradinggoose/providers/trading/alpaca/accounts.ts new file mode 100644 index 000000000..0c8fa89d2 --- /dev/null +++ b/apps/tradinggoose/providers/trading/alpaca/accounts.ts @@ -0,0 +1,112 @@ +import { buildAlpacaAuthHeaders } from '@/providers/trading/alpaca/auth' +import { resolveAlpacaTradingBaseUrl } from '@/providers/trading/alpaca/config' +import { ALPACA_DEFAULT_BASE_CURRENCY } from '@/providers/trading/alpaca/positions' +import { fetchBrokerJson, toFiniteNumber } from '@/providers/trading/portfolio-utils' +import type { + TradingPortfolioBaseContext, + UnifiedTradingAccount, + UnifiedTradingAccountStatus, + UnifiedTradingAccountType, +} from '@/providers/trading/types' + +export const mapAlpacaAccountStatus = (value: unknown): UnifiedTradingAccountStatus => { + if (typeof value !== 'string') return 'unknown' + + switch (value.trim().toUpperCase()) { + case 'ACTIVE': + case 'APPROVED': + return 'active' + case 'ACCOUNT_CLOSED': + case 'REJECTED': + case 'SUBMISSION_FAILED': + return 'closed' + case 'ACCOUNT_UPDATED': + case 'APPROVAL_PENDING': + case 'ACTION_REQUIRED': + case 'ONBOARDING': + case 'SUBMITTED': + case 'INACTIVE': + return 'restricted' + default: + return 'unknown' + } +} + +export const mapAlpacaAccountType = (account: any): UnifiedTradingAccountType => { + const multiplier = toFiniteNumber(account?.multiplier) + const maxMarginMultiplier = toFiniteNumber(account?.admin_configurations?.max_margin_multiplier) + + if ( + (typeof multiplier === 'number' && multiplier > 1) || + (typeof maxMarginMultiplier === 'number' && maxMarginMultiplier > 1) || + account?.shorting_enabled === true + ) { + return 'margin' + } + + if ( + multiplier === 1 || + maxMarginMultiplier === 1 || + account?.admin_configurations?.disable_shorting === true + ) { + return 'cash' + } + + return 'unknown' +} + +export const normalizeAlpacaTradingAccount = (account: any): UnifiedTradingAccount => { + const id = typeof account?.id === 'string' ? account.id.trim() : '' + if (!id) { + throw new Error('Alpaca account response missing account id') + } + + const accountNumber = + typeof account?.account_number === 'string' && account.account_number.trim() + ? account.account_number.trim() + : id + + return { + id, + name: `Alpaca (${accountNumber})`, + type: mapAlpacaAccountType(account), + baseCurrency: + typeof account?.currency === 'string' && account.currency.trim() + ? account.currency.trim().toUpperCase() + : ALPACA_DEFAULT_BASE_CURRENCY, + status: mapAlpacaAccountStatus(account?.status), + } +} + +export async function fetchAlpacaTradingAccount(context: TradingPortfolioBaseContext) { + const baseUrl = resolveAlpacaTradingBaseUrl(context.environment) + return fetchBrokerJson<any>({ + providerId: context.providerId, + url: `${baseUrl}/v2/account`, + init: { + method: 'GET', + headers: buildAlpacaAuthHeaders({ accessToken: context.accessToken }), + }, + }) +} + +export async function getAlpacaTradingAccounts( + context: TradingPortfolioBaseContext +): Promise<UnifiedTradingAccount[]> { + const account = await fetchAlpacaTradingAccount(context) + return [normalizeAlpacaTradingAccount(account)] +} + +export const normalizeAlpacaSnapshotAccountSummary = (account: any) => { + const totalCashValue = toFiniteNumber(account?.cash ?? 0) ?? 0 + const equity = toFiniteNumber(account?.equity ?? account?.portfolio_value ?? 0) ?? 0 + const totalPortfolioValue = toFiniteNumber(account?.portfolio_value ?? account?.equity ?? 0) ?? 0 + const buyingPower = toFiniteNumber(account?.buying_power ?? 0) ?? 0 + + return { + totalCashValue, + totalPortfolioValue, + equity, + buyingPower, + } +} diff --git a/apps/tradinggoose/providers/trading/alpaca/auth.ts b/apps/tradinggoose/providers/trading/alpaca/auth.ts index 6862027e6..bc79ab163 100644 --- a/apps/tradinggoose/providers/trading/alpaca/auth.ts +++ b/apps/tradinggoose/providers/trading/alpaca/auth.ts @@ -1,18 +1,9 @@ -import type { TradingHoldingsInput, TradingOrderInput } from '@/providers/trading/types' - -export const buildAlpacaAuthHeaders = ( - params: TradingOrderInput | TradingHoldingsInput -): Record<string, string> => { - if (params.accessToken) { - return { Authorization: `Bearer ${params.accessToken}` } - } - - if (!params.apiKey || !params.apiSecret) { - throw new Error('Alpaca access token or API key/secret are required') +export const buildAlpacaAuthHeaders = (params: { + accessToken?: string +}): Record<string, string> => { + if (!params.accessToken) { + throw new Error('Alpaca access token is required') } - return { - 'APCA-API-KEY-ID': params.apiKey, - 'APCA-API-SECRET-KEY': params.apiSecret, - } + return { Authorization: `Bearer ${params.accessToken}` } } diff --git a/apps/tradinggoose/providers/trading/alpaca/config.ts b/apps/tradinggoose/providers/trading/alpaca/config.ts index d4914de9f..b3f1c10b4 100644 --- a/apps/tradinggoose/providers/trading/alpaca/config.ts +++ b/apps/tradinggoose/providers/trading/alpaca/config.ts @@ -2,8 +2,14 @@ import type { AssetClass } from '@/providers/market/types' import { alpacaTradingSymbolRules } from '@/providers/trading/alpaca/rules' import type { TradingProviderConfig } from '@/providers/trading/providers' -const availableAssetClasses: AssetClass[] = ['stock'] -const supportsCrypto = availableAssetClasses.includes('crypto') +export const ALPACA_LIVE_TRADING_BASE_URL = 'https://api.alpaca.markets' +export const ALPACA_PAPER_TRADING_BASE_URL = 'https://paper-api.alpaca.markets' +export const ALPACA_TRADING_BASE_URL = ALPACA_LIVE_TRADING_BASE_URL + +export const resolveAlpacaTradingBaseUrl = (environment?: string | null) => + environment === 'paper' ? ALPACA_PAPER_TRADING_BASE_URL : ALPACA_LIVE_TRADING_BASE_URL + +const availableAssetClasses: AssetClass[] = ['stock', 'crypto'] const availableCryptoQuoteCodes = ['USD', 'USDC', 'USDT', 'BTC'] const availableCryptoBaseCodes = [ 'AAVE', @@ -80,46 +86,11 @@ const availability: TradingProviderConfig['availability'] = { holdings: true, availableCurrencyBase: [], availableCurrencyQuote: [], - availableCryptoBase: supportsCrypto ? availableCryptoBaseCodes : [], - availableCryptoQuote: supportsCrypto ? availableCryptoQuoteCodes : [], + availableCryptoBase: availableCryptoBaseCodes, + availableCryptoQuote: availableCryptoQuoteCodes, } const params: TradingProviderConfig['params'] = { - shared: [ - { - id: 'apiKey', - type: 'string', - title: 'API Key', - description: 'Alpaca API key ID.', - placeholder: 'APCA-API-KEY-ID', - required: false, - visibility: 'hidden', - password: true, - }, - { - id: 'apiSecret', - type: 'string', - title: 'API Secret', - description: 'Alpaca API secret key.', - placeholder: 'APCA-API-SECRET-KEY', - required: false, - visibility: 'hidden', - password: true, - }, - { - id: 'environment', - type: 'string', - title: 'Environment', - description: 'Trading environment (paper or live).', - required: false, - visibility: 'user-only', - inputType: 'dropdown', - options: [ - { id: 'paper', label: 'Paper' }, - { id: 'live', label: 'Live' }, - ], - }, - ], order: [ { id: 'orderSizingMode', @@ -167,9 +138,9 @@ export const alpacaTradingProviderConfig: TradingProviderConfig = { availability, params, api_endpoints: { - default: 'https://api.alpaca.markets', - order: 'https://api.alpaca.markets/v2/orders', - holdings: 'https://api.alpaca.markets/v2/positions', + default: ALPACA_TRADING_BASE_URL, + order: `${ALPACA_TRADING_BASE_URL}/v2/orders`, + holdings: `${ALPACA_TRADING_BASE_URL}/v2/positions`, }, capabilities: { order: { @@ -210,6 +181,7 @@ export const alpacaTradingProviderConfig: TradingProviderConfig = { }, holdings: { supportsPositions: true, + performanceWindows: ['1D', '1W', '1M', '3M', 'YTD', '1Y'], }, }, rulePrecedence: { diff --git a/apps/tradinggoose/providers/trading/alpaca/orderDetail.ts b/apps/tradinggoose/providers/trading/alpaca/orderDetail.ts index c68579963..cb026b214 100644 --- a/apps/tradinggoose/providers/trading/alpaca/orderDetail.ts +++ b/apps/tradinggoose/providers/trading/alpaca/orderDetail.ts @@ -1,6 +1,6 @@ import { buildAlpacaAuthHeaders } from '@/providers/trading/alpaca/auth' +import { resolveAlpacaTradingBaseUrl } from '@/providers/trading/alpaca/config' import type { - TradingHoldingsInput, TradingOrderDetailInput, TradingOrderDetailResult, TradingOrderHistoryRecord, @@ -38,26 +38,12 @@ export const buildAlpacaOrderDetailRequest = ( historyRecord: TradingOrderHistoryRecord, params: TradingOrderDetailInput ): TradingRequestConfig => { - const environment = - params.environment || - firstDefinedString( - historyRecord.environment, - historyRecord.request?.providerParams?.environment, - historyRecord.request?.environment - ) || - undefined - const authHeaders = buildAlpacaAuthHeaders({ accessToken: params.accessToken, - apiKey: params.apiKey, - apiSecret: params.apiSecret, - } as TradingHoldingsInput) - - const baseUrl = - environment === 'paper' ? 'https://paper-api.alpaca.markets' : 'https://api.alpaca.markets' + }) return { - url: `${baseUrl}/v2/orders/${encodeURIComponent(providerOrderId)}`, + url: `${resolveAlpacaTradingBaseUrl(params.environment)}/v2/orders/${encodeURIComponent(providerOrderId)}`, method: 'GET', headers: { ...authHeaders, @@ -145,6 +131,11 @@ export const alpacaOrderDetailRequest = async ( return { providerOrderId, - orderDetail: normalizeAlpacaOrderDetail(historyRecord.id, providerOrderId, historyRecord, rawOrder), + orderDetail: normalizeAlpacaOrderDetail( + historyRecord.id, + providerOrderId, + historyRecord, + rawOrder + ), } } diff --git a/apps/tradinggoose/providers/trading/alpaca/orders.test.ts b/apps/tradinggoose/providers/trading/alpaca/orders.test.ts index d2d65397a..3e8452f0f 100644 --- a/apps/tradinggoose/providers/trading/alpaca/orders.test.ts +++ b/apps/tradinggoose/providers/trading/alpaca/orders.test.ts @@ -11,7 +11,6 @@ const baseParams = { side: 'buy' as const, orderType: 'market' as const, timeInForce: 'day' as const, - environment: 'paper' as const, accessToken: 'test-token', } @@ -22,7 +21,7 @@ describe('buildAlpacaOrderRequest', () => { notional: 500.75, }) - expect(request.url).toBe('https://paper-api.alpaca.markets/v2/orders') + expect(request.url).toBe('https://api.alpaca.markets/v2/orders') expect(request.body).toMatchObject({ notional: 500.75, side: 'buy', diff --git a/apps/tradinggoose/providers/trading/alpaca/orders.ts b/apps/tradinggoose/providers/trading/alpaca/orders.ts index fb8cd28a2..2fc64251e 100644 --- a/apps/tradinggoose/providers/trading/alpaca/orders.ts +++ b/apps/tradinggoose/providers/trading/alpaca/orders.ts @@ -1,5 +1,9 @@ import { buildAlpacaAuthHeaders } from '@/providers/trading/alpaca/auth' -import { alpacaTradingProviderConfig } from '@/providers/trading/alpaca/config' +import { + alpacaTradingProviderConfig, + resolveAlpacaTradingBaseUrl, +} from '@/providers/trading/alpaca/config' +import { getAlpacaNotionalOrderTypeError } from '@/providers/trading/order-validation' import type { TradingOrder, TradingOrderInput, @@ -10,11 +14,6 @@ import { listingIdentityToTradingSymbol } from '@/providers/trading/utils' export const buildAlpacaOrderRequest = (params: TradingOrderInput): TradingRequestConfig => { const authHeaders = buildAlpacaAuthHeaders(params) - const baseUrl = - params.environment === 'paper' - ? 'https://paper-api.alpaca.markets' - : 'https://api.alpaca.markets' - const symbol = listingIdentityToTradingSymbol(alpacaTradingProviderConfig, { listing: params.listing, base: params.base, @@ -64,10 +63,8 @@ export const buildAlpacaOrderRequest = (params: TradingOrderInput): TradingReque const isTrailingStop = orderType === 'trailing_stop' if (useNotional) { - const supportedTypes = new Set(['market', 'limit', 'stop', 'stop_limit']) - if (!supportedTypes.has(orderType)) { - throw new Error('Alpaca notional orders support market, limit, stop, or stop_limit types.') - } + const orderTypeError = getAlpacaNotionalOrderTypeError(orderType) + if (orderTypeError) throw new Error(orderTypeError) if (timeInForce !== 'day') { throw new Error('Alpaca notional orders require time_in_force=day.') } @@ -126,7 +123,7 @@ export const buildAlpacaOrderRequest = (params: TradingOrderInput): TradingReque } return { - url: `${baseUrl}/v2/orders`, + url: `${resolveAlpacaTradingBaseUrl(params.environment)}/v2/orders`, method: 'POST', headers: { ...authHeaders, diff --git a/apps/tradinggoose/providers/trading/alpaca/performance.ts b/apps/tradinggoose/providers/trading/alpaca/performance.ts new file mode 100644 index 000000000..c27e70c39 --- /dev/null +++ b/apps/tradinggoose/providers/trading/alpaca/performance.ts @@ -0,0 +1,196 @@ +import { + fetchAlpacaTradingAccount, + normalizeAlpacaTradingAccount, +} from '@/providers/trading/alpaca/accounts' +import { buildAlpacaAuthHeaders } from '@/providers/trading/alpaca/auth' +import { + alpacaTradingProviderConfig, + resolveAlpacaTradingBaseUrl, +} from '@/providers/trading/alpaca/config' +import { + buildTradingPortfolioPerformance, + createUnavailableTradingPortfolioPerformance, + fetchBrokerJson, + toFiniteNumber, +} from '@/providers/trading/portfolio-utils' +import type { + TradingPortfolioAccountContext, + TradingPortfolioPerformanceWindow, + UnifiedTradingPortfolioPerformance, + UnifiedTradingPortfolioPerformancePoint, +} from '@/providers/trading/types' + +type AlpacaTradingPortfolioPerformanceWindow = Exclude< + TradingPortfolioPerformanceWindow, + 'MAX' +> + +const isAlpacaPerformanceWindowSupported = ( + window: TradingPortfolioPerformanceWindow +): window is AlpacaTradingPortfolioPerformanceWindow => + window !== 'MAX' && getAlpacaSupportedPerformanceWindows().includes(window) + +const getAlpacaSupportedPerformanceWindows = () => + alpacaTradingProviderConfig.capabilities?.holdings?.performanceWindows ?? [] + +const getNewYorkYear = (now: Date) => + Number( + new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + year: 'numeric', + }).format(now) + ) + +export const buildAlpacaPerformanceQueryParams = ( + window: AlpacaTradingPortfolioPerformanceWindow, + now = new Date() +) => { + const year = getNewYorkYear(now) + + switch (window) { + case '1D': + return { + period: '1D', + timeframe: '1Min', + intraday_reporting: 'market_hours', + } + case '1W': + return { + period: '1W', + timeframe: '1D', + } + case '1M': + return { + period: '1M', + timeframe: '1D', + } + case '3M': + return { + period: '3M', + timeframe: '1D', + } + case 'YTD': + return { + start: `${year}-01-01T00:00:00-05:00`, + timeframe: '1D', + } + case '1Y': + return { + period: '1A', + timeframe: '1D', + } + } +} + +const normalizeAlpacaHistoryTimestamp = (value: unknown): string | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return new Date(value * 1000).toISOString() + } + + if (typeof value !== 'string') { + return null + } + + const trimmed = value.trim() + if (!trimmed) return null + + const numeric = Number(trimmed) + if (Number.isFinite(numeric)) { + return new Date(numeric * 1000).toISOString() + } + + const parsed = Date.parse(trimmed) + if (!Number.isFinite(parsed)) { + return null + } + + return new Date(parsed).toISOString() +} + +export const normalizeAlpacaPortfolioHistoryResponse = ({ + history, + currency, + window, +}: { + history: any + currency: string + window: TradingPortfolioPerformanceWindow +}): UnifiedTradingPortfolioPerformance => { + const timestamps = Array.isArray(history?.timestamp) ? history.timestamp : null + const equity = Array.isArray(history?.equity) ? history.equity : null + + if (!timestamps || !equity) { + return createUnavailableTradingPortfolioPerformance({ + window, + supportedWindows: getAlpacaSupportedPerformanceWindows(), + unavailableReason: 'No usable performance data returned by broker', + }) + } + + const pointCount = Math.min(timestamps.length, equity.length) + const series: UnifiedTradingPortfolioPerformancePoint[] = [] + + for (let index = 0; index < pointCount; index += 1) { + const timestamp = normalizeAlpacaHistoryTimestamp(timestamps[index]) + const equityValue = toFiniteNumber(equity[index]) + if (!timestamp || typeof equityValue !== 'number') { + continue + } + + series.push({ + timestamp, + equity: equityValue, + }) + } + + series.sort((left, right) => left.timestamp.localeCompare(right.timestamp)) + + return buildTradingPortfolioPerformance({ + window, + supportedWindows: getAlpacaSupportedPerformanceWindows(), + series, + currency, + unavailableReason: 'No usable performance data returned by broker', + }) +} + +export async function getAlpacaTradingAccountPerformance( + context: TradingPortfolioAccountContext & { window: TradingPortfolioPerformanceWindow } +): Promise<UnifiedTradingPortfolioPerformance> { + if (!isAlpacaPerformanceWindowSupported(context.window)) { + return createUnavailableTradingPortfolioPerformance({ + window: context.window, + supportedWindows: getAlpacaSupportedPerformanceWindows(), + unavailableReason: `Alpaca performance window ${context.window} is not supported`, + }) + } + + const baseUrl = resolveAlpacaTradingBaseUrl(context.environment) + const searchParams = new URLSearchParams() + + for (const [key, value] of Object.entries(buildAlpacaPerformanceQueryParams(context.window))) { + if (value) { + searchParams.set(key, value) + } + } + + const [accountResponse, historyResponse] = await Promise.all([ + fetchAlpacaTradingAccount(context), + fetchBrokerJson<any>({ + providerId: context.providerId, + url: `${baseUrl}/v2/account/portfolio/history?${searchParams.toString()}`, + init: { + method: 'GET', + headers: buildAlpacaAuthHeaders({ accessToken: context.accessToken }), + }, + }), + ]) + + const normalizedAccount = normalizeAlpacaTradingAccount(accountResponse) + + return normalizeAlpacaPortfolioHistoryResponse({ + history: historyResponse, + currency: normalizedAccount.baseCurrency, + window: context.window, + }) +} diff --git a/apps/tradinggoose/providers/trading/alpaca/portfolio.test.ts b/apps/tradinggoose/providers/trading/alpaca/portfolio.test.ts new file mode 100644 index 000000000..afbf88cb7 --- /dev/null +++ b/apps/tradinggoose/providers/trading/alpaca/portfolio.test.ts @@ -0,0 +1,275 @@ +/** + * @vitest-environment node + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { normalizeAlpacaTradingAccount } from '@/providers/trading/alpaca/accounts' +import { + buildAlpacaPerformanceQueryParams, + getAlpacaTradingAccountPerformance, + normalizeAlpacaPortfolioHistoryResponse, +} from '@/providers/trading/alpaca/performance' +import { getAlpacaTradingAccountSnapshot } from '@/providers/trading/alpaca/snapshot' +import { getTradingPortfolioSupportedWindows } from '@/providers/trading/portfolio' + +describe('Alpaca portfolio helpers', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('normalizes account discovery metadata conservatively', () => { + expect( + normalizeAlpacaTradingAccount({ + id: 'acct-live', + account_number: 'PA12345', + currency: 'usd', + status: 'APPROVAL_PENDING', + multiplier: '1', + }) + ).toEqual({ + id: 'acct-live', + name: 'Alpaca (PA12345)', + type: 'cash', + baseCurrency: 'USD', + status: 'restricted', + }) + }) + + it('maps Alpaca margin indicators to a margin account type', () => { + expect( + normalizeAlpacaTradingAccount({ + id: 'acct-margin', + account_number: 'PA67890', + currency: 'USD', + status: 'ACTIVE', + multiplier: '4', + shorting_enabled: true, + }) + ).toMatchObject({ + id: 'acct-margin', + name: 'Alpaca (PA67890)', + type: 'margin', + baseCurrency: 'USD', + status: 'active', + }) + }) + + it('builds snapshot totals from account and positions', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn> + + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'acct-paper', + account_number: 'PA-1', + currency: 'USD', + status: 'ACTIVE', + cash: '2500', + equity: '10000', + portfolio_value: '10000', + buying_power: '15000', + multiplier: '2', + }), + { status: 200 } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + symbol: 'AAPL', + asset_class: 'us_equity', + side: 'long', + qty: '10', + avg_entry_price: '150', + current_price: '160', + market_value: '1600', + unrealized_pl: '100', + unrealized_plpc: '0.0666', + cost_basis: '1500', + }, + ]), + { status: 200 } + ) + ) + + const snapshot = await getAlpacaTradingAccountSnapshot({ + providerId: 'alpaca', + environment: 'live', + accessToken: 'token', + accountId: 'acct-paper', + }) + + expect(snapshot.account.id).toBe('acct-paper') + expect(snapshot.accountSummary).toMatchObject({ + totalCashValue: 2500, + totalPortfolioValue: 10000, + totalHoldingsValue: 7500, + buyingPower: 15000, + equity: 10000, + totalUnrealizedPnl: 100, + }) + expect(snapshot.cashBalances[0]?.amount).toBe(2500) + expect(snapshot.positions).toHaveLength(1) + expect(snapshot.positions[0]?.symbol.listing).toEqual({ + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }) + expect(snapshot.extra).toBeUndefined() + }) + + it('preserves negative holdings value for net-short Alpaca snapshots', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn> + + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'acct-short', + account_number: 'PA-2', + currency: 'USD', + status: 'ACTIVE', + cash: '12000', + equity: '9000', + portfolio_value: '9000', + buying_power: '0', + multiplier: '2', + }), + { status: 200 } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + symbol: 'GME', + asset_class: 'us_equity', + side: 'short', + qty: '25', + avg_entry_price: '120', + current_price: '120', + market_value: '3000', + unrealized_pl: '0', + unrealized_plpc: '0', + cost_basis: '3000', + }, + ]), + { status: 200 } + ) + ) + + const snapshot = await getAlpacaTradingAccountSnapshot({ + providerId: 'alpaca', + environment: 'live', + accessToken: 'token', + accountId: 'acct-short', + }) + + expect(snapshot.accountSummary).toMatchObject({ + totalCashValue: 12000, + totalPortfolioValue: 9000, + totalHoldingsValue: -3000, + totalUnrealizedPnl: 0, + buyingPower: 0, + equity: 9000, + }) + expect(snapshot.positions[0]?.quantity).toBe(-25) + expect(snapshot.positions[0]?.symbol.listing).toEqual({ + listing_id: 'GME', + base_id: '', + quote_id: '', + listing_type: 'default', + }) + }) + + it('maps query windows and normalizes performance series', async () => { + expect(buildAlpacaPerformanceQueryParams('YTD', new Date('2026-04-22T12:00:00.000Z'))).toEqual({ + start: '2026-01-01T00:00:00-05:00', + timeframe: '1D', + }) + expect(buildAlpacaPerformanceQueryParams('YTD', new Date('2026-01-01T03:00:00.000Z'))).toEqual({ + start: '2025-01-01T00:00:00-05:00', + timeframe: '1D', + }) + + const normalized = normalizeAlpacaPortfolioHistoryResponse({ + history: { + timestamp: [1710000100, 1710000000], + equity: [110, 100], + }, + currency: 'USD', + window: '1W', + }) + + expect(normalized.series.map((point) => point.equity)).toEqual([100, 110]) + expect(normalized.summary).toMatchObject({ + currency: 'USD', + startEquity: 100, + endEquity: 110, + highEquity: 110, + lowEquity: 100, + absoluteReturn: 10, + percentReturn: 10, + }) + }) + + it('returns an explicit unavailable payload when Alpaca history has no usable arrays', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn> + + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'acct-live', + account_number: 'LIVE-1', + currency: 'USD', + status: 'ACTIVE', + multiplier: '2', + }), + { status: 200 } + ) + ) + .mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 })) + + const performance = await getAlpacaTradingAccountPerformance({ + providerId: 'alpaca', + environment: 'live', + accessToken: 'token', + accountId: 'acct-live', + window: '1W', + }) + + expect(performance.summary).toBeNull() + expect(performance.series).toEqual([]) + expect(performance.unavailableReason).toBeTruthy() + }) + + it('returns an explicit unavailable payload for unsupported Alpaca windows', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn> + + const performance = await getAlpacaTradingAccountPerformance({ + providerId: 'alpaca', + environment: 'live', + accessToken: 'token', + accountId: 'acct-live', + window: 'MAX', + }) + + expect(fetchMock).not.toHaveBeenCalled() + expect(performance).toEqual({ + window: 'MAX', + supportedWindows: getTradingPortfolioSupportedWindows('alpaca'), + series: [], + summary: null, + unavailableReason: 'Alpaca performance window MAX is not supported', + }) + }) +}) diff --git a/apps/tradinggoose/providers/trading/alpaca/positions.test.ts b/apps/tradinggoose/providers/trading/alpaca/positions.test.ts new file mode 100644 index 000000000..eae02336e --- /dev/null +++ b/apps/tradinggoose/providers/trading/alpaca/positions.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { normalizeAlpacaPositions } from '@/providers/trading/alpaca/positions' + +describe('normalizeAlpacaPositions', () => { + it('normalizes supported broker symbols into listing identities', () => { + expect( + normalizeAlpacaPositions([ + { + symbol: 'AAPL', + asset_class: 'us_equity', + qty: '2', + side: 'long', + }, + ])[0]?.symbol.listing + ).toEqual({ + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }) + + const cryptoPosition = normalizeAlpacaPositions([ + { + symbol: 'DOGEUSD', + asset_class: 'crypto', + qty: '10', + side: 'long', + }, + ])[0] + + expect(cryptoPosition?.symbol).toMatchObject({ + base: 'DOGE', + quote: 'USD', + assetClass: 'crypto', + listing: { + listing_id: '', + base_id: 'DOGE', + quote_id: 'USD', + listing_type: 'crypto', + }, + }) + }) +}) diff --git a/apps/tradinggoose/providers/trading/alpaca/positions.ts b/apps/tradinggoose/providers/trading/alpaca/positions.ts index d0a6759f0..246d953bb 100644 --- a/apps/tradinggoose/providers/trading/alpaca/positions.ts +++ b/apps/tradinggoose/providers/trading/alpaca/positions.ts @@ -1,5 +1,9 @@ import { buildAlpacaAuthHeaders } from '@/providers/trading/alpaca/auth' -import { alpacaTradingProviderConfig } from '@/providers/trading/alpaca/config' +import { + alpacaTradingProviderConfig, + resolveAlpacaTradingBaseUrl, +} from '@/providers/trading/alpaca/config' +import { sumFiniteNumbers, toFiniteNumber } from '@/providers/trading/portfolio-utils' import type { TradingHoldingsInput, TradingHoldingsNormalizationContext, @@ -10,17 +14,9 @@ import type { } from '@/providers/trading/types' import { tradingSymbolToListingIdentity } from '@/providers/trading/utils' -const DEFAULT_BASE_CURRENCY = 'USD' +export const ALPACA_DEFAULT_BASE_CURRENCY = 'USD' -const toNumber = (value: unknown): number | undefined => { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : undefined -} - -const sumNumbers = (values: Array<number | undefined>): number => - values.reduce<number>((total, value) => (typeof value === 'number' ? total + value : total), 0) - -const mapSide = (value: unknown): UnifiedTradingPosition['side'] => { +export const mapAlpacaPositionSide = (value: unknown): UnifiedTradingPosition['side'] => { if (typeof value !== 'string') return 'unknown' const normalized = value.toLowerCase() if (normalized === 'long') return 'long' @@ -29,7 +25,7 @@ const mapSide = (value: unknown): UnifiedTradingPosition['side'] => { return 'unknown' } -const mapAssetClass = (value: unknown): UnifiedTradingSymbol['assetClass'] => { +export const mapAlpacaAssetClass = (value: unknown): UnifiedTradingSymbol['assetClass'] => { switch (value) { case 'crypto': return 'crypto' @@ -41,7 +37,7 @@ const mapAssetClass = (value: unknown): UnifiedTradingSymbol['assetClass'] => { } } -const getCurrencySymbol = (currency?: string) => { +export const getAlpacaCurrencySymbol = (currency?: string) => { switch (currency) { case 'USD': return '$' @@ -56,45 +52,26 @@ const getCurrencySymbol = (currency?: string) => { } } -export const buildAlpacaHoldingsRequest = (params: TradingHoldingsInput): TradingRequestConfig => { - const authHeaders = buildAlpacaAuthHeaders(params) - - const baseUrl = - params.environment === 'paper' - ? 'https://paper-api.alpaca.markets' - : 'https://api.alpaca.markets' - - return { - url: `${baseUrl}/v2/positions`, - method: 'GET', - headers: authHeaders, - } -} - -export const normalizeAlpacaHoldings = ( - data: any, - context?: TradingHoldingsNormalizationContext -): UnifiedTradingAccountSnapshot => { - const positions = Array.isArray(data) ? data : data?.positions || data +export const normalizeAlpacaPositions = (positions: unknown): UnifiedTradingPosition[] => { const list = Array.isArray(positions) ? positions : [] - const normalizedPositions: UnifiedTradingPosition[] = list.map((position: any) => { - const assetClass = mapAssetClass(position?.asset_class) + return list.map((position: any) => { + const assetClass = mapAlpacaAssetClass(position?.asset_class) const symbolValue = typeof position?.symbol === 'string' ? position.symbol : undefined const resolvedSymbol = tradingSymbolToListingIdentity(alpacaTradingProviderConfig, { symbol: symbolValue, assetClass, - defaultQuote: DEFAULT_BASE_CURRENCY, + defaultQuote: ALPACA_DEFAULT_BASE_CURRENCY, }) const base = resolvedSymbol?.base ?? 'UNKNOWN' - const quote = resolvedSymbol?.quote ?? DEFAULT_BASE_CURRENCY + const quote = resolvedSymbol?.quote ?? ALPACA_DEFAULT_BASE_CURRENCY const symbolAssetClass = resolvedSymbol?.assetClass ?? assetClass - const side = mapSide(position?.side) - const rawQuantity = toNumber(position?.qty ?? position?.quantity) ?? 0 + const side = mapAlpacaPositionSide(position?.side) + const rawQuantity = toFiniteNumber(position?.qty ?? position?.quantity) ?? 0 const quantity = side === 'short' ? -Math.abs(rawQuantity) : rawQuantity - const marketValue = toNumber(position?.market_value) - const unrealizedPnlPercent = toNumber(position?.unrealized_plpc) - const conversionRate = quote === DEFAULT_BASE_CURRENCY ? 1 : undefined + const marketValue = toFiniteNumber(position?.market_value) + const unrealizedPnlPercent = toFiniteNumber(position?.unrealized_plpc) + const conversionRate = quote === ALPACA_DEFAULT_BASE_CURRENCY ? 1 : undefined return { symbol: { @@ -108,23 +85,45 @@ export const normalizeAlpacaHoldings = ( }, quantity, side, - averagePrice: toNumber(position?.avg_entry_price), - marketPrice: toNumber(position?.current_price), + averagePrice: toFiniteNumber(position?.avg_entry_price), + marketPrice: toFiniteNumber(position?.current_price), marketValue, - currencySymbol: getCurrencySymbol(quote), + currencySymbol: getAlpacaCurrencySymbol(quote), conversionRate, - unrealizedPnl: toNumber(position?.unrealized_pl), + unrealizedPnl: toFiniteNumber(position?.unrealized_pl), unrealizedPnlPercent: typeof unrealizedPnlPercent === 'number' ? unrealizedPnlPercent * 100 : undefined, - costBasis: toNumber(position?.cost_basis), + costBasis: toFiniteNumber(position?.cost_basis), multiplier: 1, } }) +} + +export const sumAlpacaPositionMarketValues = (positions: UnifiedTradingPosition[]) => + sumFiniteNumbers(positions.map((position) => position.marketValue)) + +export const sumAlpacaPositionUnrealizedPnl = (positions: UnifiedTradingPosition[]) => + sumFiniteNumbers(positions.map((position) => position.unrealizedPnl)) + +export const buildAlpacaHoldingsRequest = (params: TradingHoldingsInput): TradingRequestConfig => { + const authHeaders = buildAlpacaAuthHeaders(params) + + return { + url: `${resolveAlpacaTradingBaseUrl(params.environment)}/v2/positions`, + method: 'GET', + headers: authHeaders, + } +} - const totalHoldingsValue = sumNumbers(normalizedPositions.map((position) => position.marketValue)) - const totalUnrealizedPnl = sumNumbers( - normalizedPositions.map((position) => position.unrealizedPnl) - ) +export const normalizeAlpacaHoldings = ( + data: any, + context?: TradingHoldingsNormalizationContext +): UnifiedTradingAccountSnapshot => { + const positions = Array.isArray(data) ? data : data?.positions || data + const list = Array.isArray(positions) ? positions : [] + const normalizedPositions = normalizeAlpacaPositions(list) + const totalHoldingsValue = sumAlpacaPositionMarketValues(normalizedPositions) + const totalUnrealizedPnl = sumAlpacaPositionUnrealizedPnl(normalizedPositions) const totalCashValue = 0 const totalPortfolioValue = totalHoldingsValue + totalCashValue @@ -137,7 +136,7 @@ export const normalizeAlpacaHoldings = ( account: { id: context?.accountId || 'unknown', type: 'unknown', - baseCurrency: DEFAULT_BASE_CURRENCY, + baseCurrency: ALPACA_DEFAULT_BASE_CURRENCY, status: 'unknown', }, cashBalances: [], diff --git a/apps/tradinggoose/providers/trading/alpaca/snapshot.ts b/apps/tradinggoose/providers/trading/alpaca/snapshot.ts new file mode 100644 index 000000000..49d892ff6 --- /dev/null +++ b/apps/tradinggoose/providers/trading/alpaca/snapshot.ts @@ -0,0 +1,78 @@ +import { + fetchAlpacaTradingAccount, + normalizeAlpacaSnapshotAccountSummary, + normalizeAlpacaTradingAccount, +} from '@/providers/trading/alpaca/accounts' +import { resolveAlpacaTradingBaseUrl } from '@/providers/trading/alpaca/config' +import { + ALPACA_DEFAULT_BASE_CURRENCY, + getAlpacaCurrencySymbol, + normalizeAlpacaPositions, + sumAlpacaPositionUnrealizedPnl, +} from '@/providers/trading/alpaca/positions' +import { fetchBrokerJson } from '@/providers/trading/portfolio-utils' +import type { + TradingPortfolioAccountContext, + UnifiedTradingAccountSnapshot, +} from '@/providers/trading/types' + +async function fetchAlpacaTradingPositions(context: TradingPortfolioAccountContext) { + const baseUrl = resolveAlpacaTradingBaseUrl(context.environment) + return fetchBrokerJson<any>({ + providerId: context.providerId, + url: `${baseUrl}/v2/positions`, + init: { + method: 'GET', + headers: { + Authorization: `Bearer ${context.accessToken}`, + }, + }, + }) +} + +export async function getAlpacaTradingAccountSnapshot( + context: TradingPortfolioAccountContext +): Promise<UnifiedTradingAccountSnapshot> { + const [accountResponse, positionsResponse] = await Promise.all([ + fetchAlpacaTradingAccount(context), + fetchAlpacaTradingPositions(context), + ]) + + const account = normalizeAlpacaTradingAccount(accountResponse) + const rawPositions = Array.isArray(positionsResponse) ? positionsResponse : [] + const positions = normalizeAlpacaPositions(rawPositions) + const summaryTotals = normalizeAlpacaSnapshotAccountSummary(accountResponse) + const totalUnrealizedPnl = sumAlpacaPositionUnrealizedPnl(positions) + const totalHoldingsValue = summaryTotals.totalPortfolioValue - summaryTotals.totalCashValue + + return { + asOf: new Date().toISOString(), + provider: { + name: 'Alpaca', + environment: context.environment ?? 'unknown', + }, + account: { + ...account, + baseCurrency: account.baseCurrency || ALPACA_DEFAULT_BASE_CURRENCY, + }, + cashBalances: [ + { + currency: account.baseCurrency, + currencySymbol: getAlpacaCurrencySymbol(account.baseCurrency), + amount: summaryTotals.totalCashValue, + conversionRate: account.baseCurrency === ALPACA_DEFAULT_BASE_CURRENCY ? 1 : undefined, + amountInAccountCurrency: summaryTotals.totalCashValue, + }, + ], + positions, + orders: [], + accountSummary: { + totalPortfolioValue: summaryTotals.totalPortfolioValue, + totalCashValue: summaryTotals.totalCashValue, + totalHoldingsValue, + totalUnrealizedPnl, + buyingPower: summaryTotals.buyingPower, + equity: summaryTotals.equity, + }, + } +} diff --git a/apps/tradinggoose/providers/trading/index.ts b/apps/tradinggoose/providers/trading/index.ts index 37c5c2ebe..656a9ea40 100644 --- a/apps/tradinggoose/providers/trading/index.ts +++ b/apps/tradinggoose/providers/trading/index.ts @@ -1,5 +1,7 @@ import { createLogger } from '@/lib/logs/console/logger' +import { alpacaProvider } from '@/providers/trading/alpaca' import type { TradingProvider } from '@/providers/trading/providers' +import { tradierProvider } from '@/providers/trading/tradier' import type { TradingOrderDetailInput, TradingOrderDetailResult, @@ -8,8 +10,6 @@ import type { TradingProviderRequest, TradingRequestConfig, } from '@/providers/trading/types' -import { alpacaProvider } from '@/providers/trading/alpaca' -import { tradierProvider } from '@/providers/trading/tradier' const logger = createLogger('TradingProviders') @@ -79,4 +79,5 @@ export async function executeTradingProviderOrderDetailRequest( return provider.orderDetailRequest(historyRecord, params) } +export * from './portfolio' export * from './providers' diff --git a/apps/tradinggoose/providers/trading/listing-resolution.test.ts b/apps/tradinggoose/providers/trading/listing-resolution.test.ts new file mode 100644 index 000000000..c385d7b80 --- /dev/null +++ b/apps/tradinggoose/providers/trading/listing-resolution.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveTradingPositionListingIdentity } from '@/providers/trading/listing-resolution' + +describe('trading listing resolution', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps canonical market listing identities without a search request', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + + await expect( + resolveTradingPositionListingIdentity({ + base: 'AAPL', + quote: 'USD', + assetClass: 'stock', + listing: { + listing_id: 'TG_LSTG_61E9AA', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }) + ).resolves.toEqual({ + listing_id: 'TG_LSTG_61E9AA', + base_id: '', + quote_id: '', + listing_type: 'default', + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('resolves broker stock symbols to canonical listing ids', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { + listing_id: 'TG_LSTG_61E9AA', + base_id: null, + quote_id: null, + listing_type: 'default', + base: 'AMZN', + quote: 'USD', + assetClass: 'stock', + }, + ], + }), + { status: 200 } + ) + ) + + await expect( + resolveTradingPositionListingIdentity({ + base: 'AMZN', + quote: 'USD', + assetClass: 'stock', + listing: { + listing_id: 'AMZN', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }) + ).resolves.toEqual({ + listing_id: 'TG_LSTG_61E9AA', + base_id: '', + quote_id: '', + listing_type: 'default', + }) + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/market/search?'), + expect.objectContaining({ + method: 'GET', + }) + ) + const requestUrl = String(fetchMock.mock.calls[0]?.[0]) + expect(decodeURIComponent(requestUrl)).toContain('search_query=AMZN') + expect(decodeURIComponent(requestUrl)).toContain('"asset_class":["stock"]') + }) + + it('resolves broker crypto pair codes to canonical market ids', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { + listing_id: null, + base_id: 'TG_CRYP_A0994C', + quote_id: 'TG_CURR_27AF50', + listing_type: 'crypto', + base: 'ETH', + quote: 'USD', + assetClass: 'crypto', + }, + ], + }), + { status: 200 } + ) + ) + + await expect( + resolveTradingPositionListingIdentity({ + base: 'ETH', + quote: 'USD', + assetClass: 'crypto', + listing: { + listing_id: '', + base_id: 'ETH', + quote_id: 'USD', + listing_type: 'crypto', + }, + }) + ).resolves.toEqual({ + listing_id: '', + base_id: 'TG_CRYP_A0994C', + quote_id: 'TG_CURR_27AF50', + listing_type: 'crypto', + }) + + const requestUrl = String(fetchMock.mock.calls[0]?.[0]) + expect(decodeURIComponent(requestUrl)).toContain('search_query=ETH') + expect(decodeURIComponent(requestUrl)).toContain('crypto_quote_code=USD') + expect(decodeURIComponent(requestUrl)).toContain('"asset_class":["crypto"]') + }) +}) diff --git a/apps/tradinggoose/providers/trading/listing-resolution.ts b/apps/tradinggoose/providers/trading/listing-resolution.ts new file mode 100644 index 000000000..a90b46f31 --- /dev/null +++ b/apps/tradinggoose/providers/trading/listing-resolution.ts @@ -0,0 +1,176 @@ +import { + type ListingIdentity, + type ListingOption, + type ListingType, + toListingValueObject, +} from '@/lib/listing/identity' +import { MARKET_API_VERSION } from '@/lib/market/client/constants' +import { getBaseUrl } from '@/lib/urls/utils' +import type { AssetClass } from '@/providers/market/types' +import type { UnifiedTradingSymbol } from '@/providers/trading/types' + +type MarketSearchResponse = { + data?: ListingOption[] | ListingOption | null + error?: string +} + +type TradingListingResolutionInput = Pick< + UnifiedTradingSymbol, + 'assetClass' | 'base' | 'listing' | 'quote' +> + +const MARKET_ID_PREFIX_BY_TYPE: Record<ListingType, string> = { + default: 'TG_LSTG_', + crypto: 'TG_CRYP_', + currency: 'TG_CURR_', +} + +const buildMarketSearchUrl = (params: URLSearchParams) => { + const relativeUrl = `/api/market/search?${params.toString()}` + if (typeof window !== 'undefined') return relativeUrl + return new URL(relativeUrl, getBaseUrl()).toString() +} + +const normalizeCode = (value?: string | null) => { + const trimmed = value?.trim() + return trimmed ? trimmed.toUpperCase() : null +} + +const isMarketReferenceId = (value: string, listingType: ListingType) => + value.toUpperCase().startsWith(MARKET_ID_PREFIX_BY_TYPE[listingType]) + +const isCanonicalMarketIdentity = (listing: ListingIdentity) => { + if (listing.listing_type === 'default') { + return isMarketReferenceId(listing.listing_id, 'default') + } + + if (!isMarketReferenceId(listing.base_id, listing.listing_type)) return false + + const quoteType = listing.quote_id.toUpperCase().startsWith(MARKET_ID_PREFIX_BY_TYPE.crypto) + ? 'crypto' + : 'currency' + return isMarketReferenceId(listing.quote_id, quoteType) +} + +const resolveListingType = ( + listing: ListingIdentity | null, + assetClass?: AssetClass | null +): ListingType => { + if (listing?.listing_type) return listing.listing_type + if (assetClass === 'crypto') return 'crypto' + if (assetClass === 'currency') return 'currency' + return 'default' +} + +const getSearchAssetClass = (listingType: ListingType, assetClass?: AssetClass | null) => { + if (listingType !== 'default') return listingType + if (assetClass && assetClass !== 'crypto' && assetClass !== 'currency') return assetClass + return undefined +} + +const getListingBaseCode = ( + input: TradingListingResolutionInput, + listing: ListingIdentity | null, + listingType: ListingType +) => { + if (input.base) return normalizeCode(input.base) + if (!listing) return null + return normalizeCode(listingType === 'default' ? listing.listing_id : listing.base_id) +} + +const getListingQuoteCode = ( + input: TradingListingResolutionInput, + listing: ListingIdentity | null, + listingType: ListingType +) => { + if (input.quote) return normalizeCode(input.quote) + if (!listing || listingType === 'default') return null + return normalizeCode(listing.quote_id) +} + +const readSearchRows = (payload: MarketSearchResponse): ListingOption[] => { + if (!payload.data) return [] + return Array.isArray(payload.data) ? payload.data : [payload.data] +} + +const matchesTradingSymbol = ({ + row, + listingType, + baseCode, + quoteCode, +}: { + row: ListingOption + listingType: ListingType + baseCode: string + quoteCode?: string | null +}) => { + if (row.listing_type !== listingType) return false + if (normalizeCode(row.base) !== baseCode) return false + if (quoteCode && normalizeCode(row.quote) !== quoteCode) return false + return true +} + +const fetchCanonicalListing = async ({ + listingType, + assetClass, + baseCode, + quoteCode, + signal, +}: { + listingType: ListingType + assetClass?: AssetClass | null + baseCode: string + quoteCode?: string | null + signal?: AbortSignal +}) => { + const params = new URLSearchParams({ + search_query: baseCode, + version: MARKET_API_VERSION, + }) + const filters: Record<string, unknown> = { limit: 10 } + const searchAssetClass = getSearchAssetClass(listingType, assetClass) + if (searchAssetClass) filters.asset_class = [searchAssetClass] + + params.set('filters', JSON.stringify(filters)) + if (quoteCode) { + if (listingType === 'crypto') params.set('crypto_quote_code', quoteCode) + if (listingType === 'currency') params.set('currency_quote_code', quoteCode) + if (listingType === 'default') params.set('listing_quote_code', quoteCode) + } + + const response = await fetch(buildMarketSearchUrl(params), { + method: 'GET', + headers: { Accept: 'application/json' }, + signal, + }) + if (!response.ok) return null + + const payload = (await response.json().catch(() => ({}))) as MarketSearchResponse + const row = readSearchRows(payload).find((candidate) => + matchesTradingSymbol({ row: candidate, listingType, baseCode, quoteCode }) + ) + return row ? toListingValueObject(row) : null +} + +export async function resolveTradingPositionListingIdentity( + input: TradingListingResolutionInput, + signal?: AbortSignal +): Promise<ListingIdentity | null> { + const listing = toListingValueObject(input.listing) + if (listing && isCanonicalMarketIdentity(listing)) return listing + + const listingType = resolveListingType(listing, input.assetClass) + const baseCode = getListingBaseCode(input, listing, listingType) + if (!baseCode) return null + + const quoteCode = getListingQuoteCode(input, listing, listingType) + if (listingType !== 'default' && !quoteCode) return null + + return fetchCanonicalListing({ + listingType, + assetClass: input.assetClass, + baseCode, + quoteCode, + signal, + }) +} diff --git a/apps/tradinggoose/providers/trading/order-types.test.ts b/apps/tradinggoose/providers/trading/order-types.test.ts new file mode 100644 index 000000000..6fb882205 --- /dev/null +++ b/apps/tradinggoose/providers/trading/order-types.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import type { ListingResolved } from '@/lib/listing/identity' +import { + getStrictTradingOrderTypeDefinitions, + getTradingOrderTypeOptions, +} from '@/providers/trading/order-types' + +const stockListing: ListingResolved = { + listing_type: 'default' as const, + listing_id: 'AAPL', + base_id: '', + quote_id: '', + base: 'AAPL', + quote: 'USD', + assetClass: 'stock', +} + +const cryptoListing: ListingResolved = { + listing_type: 'crypto' as const, + listing_id: '', + base_id: 'BTC', + quote_id: 'USD', + base: 'BTC', + quote: 'USD', +} + +const etfListing: ListingResolved = { + listing_type: 'default' as const, + listing_id: 'SPY', + base_id: '', + quote_id: '', + base: 'SPY', + quote: 'USD', + assetClass: 'etf', +} + +const assetlessListing: ListingResolved = { + listing_type: 'default' as const, + listing_id: 'MSFT', + base_id: '', + quote_id: '', + base: 'MSFT', + quote: 'USD', +} + +describe('trading order type helpers', () => { + it('uses strict listing/order-class filtering for quick order decisions', () => { + expect(getStrictTradingOrderTypeDefinitions('tradier', { listing: stockListing })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'market' }), + expect.objectContaining({ id: 'limit' }), + ]) + ) + expect(getStrictTradingOrderTypeDefinitions('tradier', { listing: stockListing })).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'debit' })]) + ) + }) + + it('keeps fallback options for generic callers while strict definitions stay empty', () => { + expect(getStrictTradingOrderTypeDefinitions('tradier', { listing: cryptoListing })).toEqual([]) + expect( + getTradingOrderTypeOptions('tradier', { listing: cryptoListing }).length + ).toBeGreaterThan(0) + }) + + it('applies provider availability and order-class filters without hiding generic fallbacks', () => { + expect( + getStrictTradingOrderTypeDefinitions('tradier', { listing: assetlessListing }).length + ).toBeGreaterThan(0) + expect(getStrictTradingOrderTypeDefinitions('alpaca', { listing: etfListing })).toEqual([]) + expect( + getStrictTradingOrderTypeDefinitions('alpaca', { listing: cryptoListing }).map( + (definition) => definition.id + ) + ).toEqual(['market', 'limit', 'stop_limit']) + expect(getTradingOrderTypeOptions('alpaca', { listing: etfListing })).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'market' })]) + ) + + expect( + getStrictTradingOrderTypeDefinitions('tradier', { + listing: stockListing, + orderClass: 'multileg', + }).map((definition) => definition.id) + ).toEqual(['market', 'debit', 'credit', 'even']) + }) +}) diff --git a/apps/tradinggoose/providers/trading/order-types.ts b/apps/tradinggoose/providers/trading/order-types.ts index d87136968..64eaf1f67 100644 --- a/apps/tradinggoose/providers/trading/order-types.ts +++ b/apps/tradinggoose/providers/trading/order-types.ts @@ -1,18 +1,8 @@ import type { ListingInputValue } from '@/lib/listing/identity' -import type { AssetClass } from '@/providers/market/types' -import { getTradingProviderConfig } from '@/providers/trading/providers' import type { TradingOrderTypeDefinition } from '@/providers/trading/providers' +import { getTradingProviderConfig } from '@/providers/trading/providers' import type { TradingProviderId } from '@/providers/trading/types' - -const ASSET_CLASS_SET = new Set<AssetClass>([ - 'stock', - 'etf', - 'future', - 'currency', - 'crypto', - 'indice', - 'mutualfund', -]) +import { resolveTradingListingAssetClass } from '@/providers/trading/utils' const toTitleCase = (value: string): string => value @@ -28,26 +18,6 @@ const normalizeOrderClass = (value: unknown): string | undefined => { return trimmed ? trimmed : undefined } -const resolveAssetClass = (listing?: ListingInputValue): AssetClass | undefined => { - if (!listing || typeof listing === 'string') return undefined - const record = listing as Record<string, unknown> - - const direct = record.assetClass ?? record.base_asset_class ?? record.quote_asset_class - if (typeof direct === 'string' && ASSET_CLASS_SET.has(direct as AssetClass)) { - return direct as AssetClass - } - - const listingType = typeof record.listing_type === 'string' ? record.listing_type : undefined - if (listingType === 'crypto' || listingType === 'currency') { - return listingType as AssetClass - } - if (listingType === 'equity') { - return 'stock' - } - - return undefined -} - const normalizeOrderTypeDefinitions = ( orderTypes?: TradingOrderTypeDefinition[] ): TradingOrderTypeDefinition[] => { @@ -55,24 +25,33 @@ const normalizeOrderTypeDefinitions = ( return orderTypes } -export function getTradingOrderTypeOptions( +export function getStrictTradingOrderTypeDefinitions( providerId?: TradingProviderId, context: { listing?: ListingInputValue orderClass?: string } = {} -): Array<{ id: string; label: string }> { +): TradingOrderTypeDefinition[] { if (!providerId) return [] const config = getTradingProviderConfig(providerId) + if (!config) return [] const definitions = normalizeOrderTypeDefinitions(config?.capabilities?.order?.orderTypes) if (!definitions.length) return [] - const assetClass = resolveAssetClass(context.listing) + const assetClass = resolveTradingListingAssetClass(context.listing) + if ( + assetClass && + config.availability.assetClass.length > 0 && + !config.availability.assetClass.includes(assetClass) + ) { + return [] + } + const normalizedOrderClass = normalizeOrderClass(context.orderClass) ?? (providerId === 'tradier' ? 'equity' : undefined) - const filtered = definitions.filter((definition) => { + return definitions.filter((definition) => { if (assetClass && definition.assetClasses?.length) { if (!definition.assetClasses.includes(assetClass)) return false } @@ -81,8 +60,35 @@ export function getTradingOrderTypeOptions( } return true }) +} + +export function getTradingOrderTypeDefinitions( + providerId?: TradingProviderId, + context: { + listing?: ListingInputValue + orderClass?: string + } = {} +): TradingOrderTypeDefinition[] { + if (!providerId) return [] + + const config = getTradingProviderConfig(providerId) + const definitions = normalizeOrderTypeDefinitions(config?.capabilities?.order?.orderTypes) + if (!definitions.length) return [] + + const filtered = getStrictTradingOrderTypeDefinitions(providerId, context) + return filtered.length ? filtered : definitions +} + +export function getTradingOrderTypeOptions( + providerId?: TradingProviderId, + context: { + listing?: ListingInputValue + orderClass?: string + } = {} +): Array<{ id: string; label: string }> { + const resultSource = getTradingOrderTypeDefinitions(providerId, context) + if (!resultSource.length) return [] - const resultSource = filtered.length ? filtered : definitions const seen = new Set<string>() return resultSource.reduce<Array<{ id: string; label: string }>>((acc, definition) => { diff --git a/apps/tradinggoose/providers/trading/order-validation.ts b/apps/tradinggoose/providers/trading/order-validation.ts new file mode 100644 index 000000000..ede244eb7 --- /dev/null +++ b/apps/tradinggoose/providers/trading/order-validation.ts @@ -0,0 +1,17 @@ +export const ALPACA_NOTIONAL_ORDER_TYPE_ERROR = + 'Alpaca notional orders support market, limit, stop, or stop_limit types.' + +export const ALPACA_TRAILING_STOP_TRAIL_VALUE_ERROR = 'Enter either trail price or trail percent.' + +const ALPACA_NOTIONAL_ORDER_TYPES = new Set(['market', 'limit', 'stop', 'stop_limit']) + +const normalizeOrderType = (orderType?: string | null) => { + const normalized = orderType?.trim().toLowerCase() + return normalized || 'market' +} + +export function getAlpacaNotionalOrderTypeError(orderType?: string | null): string | null { + return ALPACA_NOTIONAL_ORDER_TYPES.has(normalizeOrderType(orderType)) + ? null + : ALPACA_NOTIONAL_ORDER_TYPE_ERROR +} diff --git a/apps/tradinggoose/providers/trading/portfolio-utils.test.ts b/apps/tradinggoose/providers/trading/portfolio-utils.test.ts new file mode 100644 index 000000000..3189c4525 --- /dev/null +++ b/apps/tradinggoose/providers/trading/portfolio-utils.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { + buildTradingPortfolioPerformance, + buildTradingPortfolioPerformanceSummary, +} from '@/providers/trading/portfolio-utils' + +describe('trading portfolio performance helpers', () => { + it('treats a single-point series as flat performance with a null percent return', () => { + const summary = buildTradingPortfolioPerformanceSummary( + [{ timestamp: '2026-04-22T00:00:00.000Z', equity: 2500 }], + 'USD' + ) + + expect(summary).toEqual({ + currency: 'USD', + startEquity: 2500, + endEquity: 2500, + highEquity: 2500, + lowEquity: 2500, + absoluteReturn: 0, + percentReturn: null, + asOf: '2026-04-22T00:00:00.000Z', + }) + }) + + it('returns a null percent when the starting equity is zero', () => { + const performance = buildTradingPortfolioPerformance({ + window: '1D', + supportedWindows: ['1D', '1W'], + currency: 'USD', + series: [ + { timestamp: '2026-04-21T00:00:00.000Z', equity: 0 }, + { timestamp: '2026-04-22T00:00:00.000Z', equity: 100 }, + ], + }) + + expect(performance.summary).toMatchObject({ + currency: 'USD', + startEquity: 0, + endEquity: 100, + absoluteReturn: 100, + percentReturn: null, + }) + }) +}) diff --git a/apps/tradinggoose/providers/trading/portfolio-utils.ts b/apps/tradinggoose/providers/trading/portfolio-utils.ts new file mode 100644 index 000000000..ba693c152 --- /dev/null +++ b/apps/tradinggoose/providers/trading/portfolio-utils.ts @@ -0,0 +1,161 @@ +import type { + TradingPortfolioPerformanceWindow, + UnifiedTradingPortfolioPerformance, + UnifiedTradingPortfolioPerformancePoint, + UnifiedTradingPortfolioPerformanceSummary, +} from '@/providers/trading/types' + +export class TradingBrokerRequestError extends Error { + status: number + providerId: string + url: string + payload?: unknown + + constructor(input: { + message: string + providerId: string + status: number + url: string + payload?: unknown + }) { + super(input.message) + this.name = 'TradingBrokerRequestError' + this.status = input.status + this.providerId = input.providerId + this.url = input.url + this.payload = input.payload + } +} + +export const toFiniteNumber = (value: unknown): number | undefined => { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +export const sumFiniteNumbers = (values: Array<number | undefined>): number => + values.reduce<number>((total, value) => (typeof value === 'number' ? total + value : total), 0) + +export const createUnavailableTradingPortfolioPerformance = ({ + window, + supportedWindows, + unavailableReason, +}: { + window: TradingPortfolioPerformanceWindow + supportedWindows: TradingPortfolioPerformanceWindow[] + unavailableReason: string +}): UnifiedTradingPortfolioPerformance => ({ + window, + supportedWindows, + series: [], + summary: null, + unavailableReason, +}) + +export const buildTradingPortfolioPerformanceSummary = ( + series: UnifiedTradingPortfolioPerformancePoint[], + currency: string +): UnifiedTradingPortfolioPerformanceSummary | null => { + if (series.length === 0) { + return null + } + + const startPoint = series[0] + const endPoint = series[series.length - 1] + if (!startPoint || !endPoint) { + return null + } + + let highEquity = startPoint.equity + let lowEquity = startPoint.equity + + for (const point of series) { + if (point.equity > highEquity) { + highEquity = point.equity + } + if (point.equity < lowEquity) { + lowEquity = point.equity + } + } + + const absoluteReturn = endPoint.equity - startPoint.equity + const percentReturn = + series.length < 2 || startPoint.equity === 0 ? null : (absoluteReturn / startPoint.equity) * 100 + + return { + currency, + startEquity: startPoint.equity, + endEquity: endPoint.equity, + highEquity, + lowEquity, + absoluteReturn: series.length < 2 ? 0 : absoluteReturn, + percentReturn, + asOf: endPoint.timestamp, + } +} + +export const buildTradingPortfolioPerformance = ({ + window, + supportedWindows, + series, + currency, + unavailableReason, +}: { + window: TradingPortfolioPerformanceWindow + supportedWindows: TradingPortfolioPerformanceWindow[] + series: UnifiedTradingPortfolioPerformancePoint[] + currency: string + unavailableReason?: string +}): UnifiedTradingPortfolioPerformance => { + const summary = buildTradingPortfolioPerformanceSummary(series, currency) + + if (!summary) { + return createUnavailableTradingPortfolioPerformance({ + window, + supportedWindows, + unavailableReason: unavailableReason ?? 'No usable performance data returned by broker', + }) + } + + return { + window, + supportedWindows, + series, + summary, + } +} + +export async function fetchBrokerJson<T>({ + providerId, + url, + init, +}: { + providerId: string + url: string + init?: RequestInit +}): Promise<T> { + let response: Response + + try { + response = await fetch(url, init) + } catch (error) { + throw new TradingBrokerRequestError({ + message: error instanceof Error ? error.message : 'Broker request failed', + providerId, + status: 0, + url, + }) + } + + const payload = await response.json().catch(() => undefined) + if (!response.ok) { + throw new TradingBrokerRequestError({ + message: `Broker request failed with status ${response.status}`, + providerId, + status: response.status, + url, + payload, + }) + } + + return payload as T +} diff --git a/apps/tradinggoose/providers/trading/portfolio.test.ts b/apps/tradinggoose/providers/trading/portfolio.test.ts new file mode 100644 index 000000000..1d3069c35 --- /dev/null +++ b/apps/tradinggoose/providers/trading/portfolio.test.ts @@ -0,0 +1,28 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { + getTradingPortfolioSupportedWindows, + isTradingPortfolioWindowSupported, +} from '@/providers/trading/portfolio' +import { getTradingHoldingsCapabilities } from '@/providers/trading/providers' + +describe('Trading portfolio window contract', () => { + it('reuses the provider definition supported window lists', () => { + expect(getTradingPortfolioSupportedWindows('alpaca')).toEqual( + getTradingHoldingsCapabilities('alpaca')?.performanceWindows + ) + expect(getTradingPortfolioSupportedWindows('tradier')).toEqual( + getTradingHoldingsCapabilities('tradier')?.performanceWindows + ) + }) + + it('rejects unsupported windows without requiring a typed window input', () => { + expect(isTradingPortfolioWindowSupported('alpaca', '1D')).toBe(true) + expect(isTradingPortfolioWindowSupported('alpaca', 'MAX')).toBe(false) + expect(isTradingPortfolioWindowSupported('tradier', 'MAX')).toBe(true) + expect(isTradingPortfolioWindowSupported('tradier', '3M')).toBe(false) + }) +}) diff --git a/apps/tradinggoose/providers/trading/portfolio.ts b/apps/tradinggoose/providers/trading/portfolio.ts new file mode 100644 index 000000000..2e627e26f --- /dev/null +++ b/apps/tradinggoose/providers/trading/portfolio.ts @@ -0,0 +1,66 @@ +import { getAlpacaTradingAccounts } from '@/providers/trading/alpaca/accounts' +import { getAlpacaTradingAccountPerformance } from '@/providers/trading/alpaca/performance' +import { getAlpacaTradingAccountSnapshot } from '@/providers/trading/alpaca/snapshot' +import { getTradierTradingAccounts } from '@/providers/trading/tradier/accounts' +import { getTradierTradingAccountPerformance } from '@/providers/trading/tradier/performance' +import { getTradierTradingAccountSnapshot } from '@/providers/trading/tradier/snapshot' +import { getTradingHoldingsCapabilities } from '@/providers/trading/providers' +import type { + TradingPortfolioAccountContext, + TradingPortfolioBaseContext, + TradingPortfolioPerformanceWindow, + TradingProviderId, + UnifiedTradingAccount, + UnifiedTradingAccountSnapshot, + UnifiedTradingPortfolioPerformance, +} from '@/providers/trading/types' + +export const getTradingPortfolioSupportedWindows = ( + providerId: TradingProviderId +): TradingPortfolioPerformanceWindow[] => { + return [...(getTradingHoldingsCapabilities(providerId)?.performanceWindows ?? [])] +} + +export const isTradingPortfolioWindowSupported = (providerId: TradingProviderId, window: string) => + getTradingPortfolioSupportedWindows(providerId).some( + (supportedWindow) => supportedWindow === window + ) + +export async function listTradingAccounts( + context: TradingPortfolioBaseContext +): Promise<UnifiedTradingAccount[]> { + switch (context.providerId) { + case 'alpaca': + return getAlpacaTradingAccounts(context) + case 'tradier': + return getTradierTradingAccounts(context) + default: + throw new Error(`Unsupported trading provider: ${context.providerId}`) + } +} + +export async function getTradingAccountSnapshot( + context: TradingPortfolioAccountContext +): Promise<UnifiedTradingAccountSnapshot> { + switch (context.providerId) { + case 'alpaca': + return getAlpacaTradingAccountSnapshot(context) + case 'tradier': + return getTradierTradingAccountSnapshot(context) + default: + throw new Error(`Unsupported trading provider: ${context.providerId}`) + } +} + +export async function getTradingAccountPerformance( + context: TradingPortfolioAccountContext & { window: TradingPortfolioPerformanceWindow } +): Promise<UnifiedTradingPortfolioPerformance> { + switch (context.providerId) { + case 'alpaca': + return getAlpacaTradingAccountPerformance(context) + case 'tradier': + return getTradierTradingAccountPerformance(context) + default: + throw new Error(`Unsupported trading provider: ${context.providerId}`) + } +} diff --git a/apps/tradinggoose/providers/trading/providers.ts b/apps/tradinggoose/providers/trading/providers.ts index 207fb28c4..64159657d 100644 --- a/apps/tradinggoose/providers/trading/providers.ts +++ b/apps/tradinggoose/providers/trading/providers.ts @@ -1,18 +1,20 @@ import type React from 'react' +import { getCanonicalScopesForProvider } from '@/lib/oauth' import type { AssetClass } from '@/providers/market/types' import { alpacaTradingProviderConfig } from '@/providers/trading/alpaca/config' import { tradierTradingProviderConfig } from '@/providers/trading/tradier/config' import type { TradingAuthType, - TradingOrderDetailInput, - TradingOrderDetailResult, - TradingOrderHistoryRecord, TradingFieldDefinition, TradingHoldingsInput, TradingHoldingsNormalizationContext, TradingOperationKind, TradingOrder, + TradingOrderDetailInput, + TradingOrderDetailResult, + TradingOrderHistoryRecord, TradingOrderInput, + TradingPortfolioPerformanceWindow, TradingProviderId, TradingProviderOAuthConfig, TradingRequestConfig, @@ -42,6 +44,7 @@ export interface TradingOrderInputCapabilities { export interface TradingHoldingsInputCapabilities { supportsPositions?: boolean + performanceWindows?: TradingPortfolioPerformanceWindow[] } export interface TradingProviderCapabilities { @@ -200,14 +203,17 @@ export const TRADING_PROVIDER_DEFINITIONS: Record<string, TradingProviderDefinit alpaca: { id: 'alpaca', name: 'Alpaca', - description: 'Commission-free trading via Alpaca (paper and live).', + description: 'Commission-free trading via Alpaca.', authType: 'oauth', oauth: { provider: 'alpaca', - serviceId: 'alpaca', - scopes: ['account:write', 'trading', 'data'], + credentialServices: [ + { serviceId: 'alpaca-live', environment: 'live' }, + { serviceId: 'alpaca-paper', environment: 'paper' }, + ], + scopes: getCanonicalScopesForProvider('alpaca-live'), credentialTitle: 'Alpaca Account', - credentialPlaceholder: 'Select Alpaca account', + credentialPlaceholder: 'Select or connect Alpaca connection', }, credentialFields: [], defaults: { @@ -226,7 +232,7 @@ export const TRADING_PROVIDER_DEFINITIONS: Record<string, TradingProviderDefinit serviceId: 'tradier', scopes: ['read', 'write', 'trade'], credentialTitle: 'Tradier Account', - credentialPlaceholder: 'Select or connect Tradier account', + credentialPlaceholder: 'Select or connect Tradier connection', }, fields: [ { @@ -276,6 +282,12 @@ export function getTradingProviderCapabilities( return TRADING_PROVIDER_DEFINITIONS[providerId]?.config.capabilities || null } +export function getTradingHoldingsCapabilities( + providerId: TradingProviderId +): TradingHoldingsInputCapabilities | null { + return getTradingProviderCapabilities(providerId)?.holdings || null +} + export function getTradingProviderKinds(providerId: TradingProviderId): TradingOperationKind[] { const availability = getTradingProviderAvailability(providerId) const kinds = new Set<TradingOperationKind>() @@ -290,11 +302,62 @@ export function getTradingProviders(): TradingProviderDefinition[] { return Object.values(TRADING_PROVIDER_DEFINITIONS) } -export function getTradingProviderOptions(): Array<{ id: string; name: string }> { - return Object.values(TRADING_PROVIDER_DEFINITIONS).map((provider) => ({ - id: provider.id, - name: provider.name, - })) +export function getTradingProviderOAuthCredentialServices(providerId: TradingProviderId) { + const provider = getTradingProviderDefinition(providerId) + if (!provider?.oauth) return null + if (provider.oauth.credentialServices?.length) return provider.oauth.credentialServices + + const serviceId = provider.oauth.serviceId ?? provider.oauth.provider + return serviceId ? [{ serviceId, environment: 'live' as const }] : [] +} + +export function getTradingProviderOAuthServiceIds(providerId: TradingProviderId): string[] { + return (getTradingProviderOAuthCredentialServices(providerId) ?? []).map( + (service) => service.serviceId + ) +} + +export function resolveTradingProviderOAuthCredentialService( + providerId: TradingProviderId, + serviceId?: string | null +) { + const services = getTradingProviderOAuthCredentialServices(providerId) + if (!services || services.length === 0) return null + + const requestedServiceId = serviceId?.trim() + if (requestedServiceId) { + return services.find((service) => service.serviceId === requestedServiceId) ?? null + } + + return services.length === 1 ? (services[0] ?? null) : null +} + +export function getTradingProviderOAuthServiceId( + providerId: TradingProviderId, + serviceId?: string | null +): string | null { + return resolveTradingProviderOAuthCredentialService(providerId, serviceId)?.serviceId ?? null +} + +export function getTradingProviderOAuthEnvironment( + providerId: TradingProviderId, + serviceId?: string | null +) { + return resolveTradingProviderOAuthCredentialService(providerId, serviceId)?.environment ?? null +} + +export function getTradingProviderOAuthServiceIdForEnvironment( + providerId: TradingProviderId, + environment?: string | null +) { + const normalizedEnvironment = environment?.trim() + const services = getTradingProviderOAuthCredentialServices(providerId) + if (!services || services.length === 0) return null + if (normalizedEnvironment) { + const service = services.find((candidate) => candidate.environment === normalizedEnvironment) + if (service) return service.serviceId + } + return services.length === 1 ? (services[0]?.serviceId ?? null) : null } export function getTradingProvidersByKind(kind: TradingOperationKind): TradingProviderDefinition[] { @@ -314,6 +377,29 @@ export function getTradingProviderOptionsByKind( })) } +export function getAvailableTradingProviders( + providerAvailability: Record<string, boolean>, + kind?: TradingOperationKind +): TradingProviderDefinition[] { + const providers = kind ? getTradingProvidersByKind(kind) : getTradingProviders() + + return providers.filter((provider) => { + const oauthServiceIds = getTradingProviderOAuthServiceIds(provider.id) + if (oauthServiceIds.length === 0) return true + return oauthServiceIds.some((oauthServiceId) => Boolean(providerAvailability[oauthServiceId])) + }) +} + +export function getAvailableTradingProviderOptions( + providerAvailability: Record<string, boolean>, + kind?: TradingOperationKind +): Array<{ id: string; name: string }> { + return getAvailableTradingProviders(providerAvailability, kind).map((provider) => ({ + id: provider.id, + name: provider.name, + })) +} + export interface TradingProviderParamRegistryEntry { definition: TradingProviderParamDefinition providers: string[] diff --git a/apps/tradinggoose/providers/trading/tradier/accounts.ts b/apps/tradinggoose/providers/trading/tradier/accounts.ts new file mode 100644 index 000000000..02d35820d --- /dev/null +++ b/apps/tradinggoose/providers/trading/tradier/accounts.ts @@ -0,0 +1,67 @@ +import { fetchBrokerJson } from '@/providers/trading/portfolio-utils' +import { buildTradierAuthHeaders, resolveTradierBaseUrl } from '@/providers/trading/tradier/client' +import { + mapTradierAccountType, + TRADIER_DEFAULT_BASE_CURRENCY, +} from '@/providers/trading/tradier/positions' +import type { + TradingPortfolioBaseContext, + UnifiedTradingAccount, + UnifiedTradingAccountStatus, +} from '@/providers/trading/types' + +export const mapTradierAccountStatus = (value: unknown): UnifiedTradingAccountStatus => { + if (typeof value !== 'string') return 'unknown' + const normalized = value.trim().toLowerCase() + if (normalized === 'active') return 'active' + if (normalized === 'closed') return 'closed' + return 'unknown' +} + +const toTradierAccountsArray = (profileResponse: any) => { + const accounts = profileResponse?.profile?.account ?? profileResponse?.account ?? [] + if (Array.isArray(accounts)) return accounts + if (!accounts) return [] + return [accounts] +} + +export const normalizeTradierTradingAccount = (account: any): UnifiedTradingAccount => { + const accountNumber = + typeof account?.account_number === 'string' ? account.account_number.trim() : '' + if (!accountNumber) { + throw new Error('Tradier profile response missing account number') + } + + const classification = + typeof account?.classification === 'string' ? account.classification.trim() : '' + + return { + id: accountNumber, + name: classification ? `${classification} (${accountNumber})` : accountNumber, + type: mapTradierAccountType(account?.type), + baseCurrency: TRADIER_DEFAULT_BASE_CURRENCY, + status: mapTradierAccountStatus(account?.status), + } +} + +export async function fetchTradierTradingProfile(context: TradingPortfolioBaseContext) { + const baseUrl = resolveTradierBaseUrl(context.environment) + return fetchBrokerJson<any>({ + providerId: context.providerId, + url: `${baseUrl}/user/profile`, + init: { + method: 'GET', + headers: { + ...buildTradierAuthHeaders({ accessToken: context.accessToken }), + Accept: 'application/json', + }, + }, + }) +} + +export async function getTradierTradingAccounts( + context: TradingPortfolioBaseContext +): Promise<UnifiedTradingAccount[]> { + const profile = await fetchTradierTradingProfile(context) + return toTradierAccountsArray(profile).map(normalizeTradierTradingAccount) +} diff --git a/apps/tradinggoose/providers/trading/tradier/config.ts b/apps/tradinggoose/providers/trading/tradier/config.ts index 018a60a1d..f37d5339b 100644 --- a/apps/tradinggoose/providers/trading/tradier/config.ts +++ b/apps/tradinggoose/providers/trading/tradier/config.ts @@ -136,6 +136,7 @@ export const tradierTradingProviderConfig: TradingProviderConfig = { }, holdings: { supportsPositions: true, + performanceWindows: ['1W', '1M', 'YTD', '1Y', 'MAX'], }, }, rulePrecedence: { diff --git a/apps/tradinggoose/providers/trading/tradier/orderDetail.ts b/apps/tradinggoose/providers/trading/tradier/orderDetail.ts index deebf383d..1ef25b69f 100644 --- a/apps/tradinggoose/providers/trading/tradier/orderDetail.ts +++ b/apps/tradinggoose/providers/trading/tradier/orderDetail.ts @@ -55,14 +55,7 @@ export const buildTradierOrderDetailRequest = ( ) } - const environment = - params.environment || - firstDefinedString( - historyRecord.environment, - historyRecord.request?.providerParams?.environment, - historyRecord.request?.environment - ) || - undefined + const environment = params.environment || firstDefinedString(historyRecord.environment) || undefined const baseUrl = resolveTradierBaseUrl(environment ?? undefined) const authHeaders = buildTradierAuthHeaders({ accessToken: params.accessToken, diff --git a/apps/tradinggoose/providers/trading/tradier/orders.test.ts b/apps/tradinggoose/providers/trading/tradier/orders.test.ts new file mode 100644 index 000000000..65651a91a --- /dev/null +++ b/apps/tradinggoose/providers/trading/tradier/orders.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { ListingResolved } from '@/lib/listing/identity' +import { buildTradierOrderRequest } from '@/providers/trading/tradier/orders' + +const stockListing: ListingResolved = { + listing_type: 'default' as const, + listing_id: 'AAPL', + base_id: '', + quote_id: '', + base: 'AAPL', + quote: 'USD', + assetClass: 'stock' as const, +} + +describe('Tradier order request builder', () => { + it('requires accountId and quantity', () => { + expect(() => + buildTradierOrderRequest({ + listing: stockListing, + side: 'buy', + quantity: 1, + accessToken: 'token', + }) + ).toThrow('Tradier account ID is required') + + expect(() => + buildTradierOrderRequest({ + listing: stockListing, + side: 'buy', + accountId: 'ACC-1', + accessToken: 'token', + }) + ).toThrow('Quantity is required for Tradier orders') + }) + + it('defaults order class to equity and creates the form body', () => { + const request = buildTradierOrderRequest({ + listing: stockListing, + side: 'sell', + quantity: 2, + accountId: 'ACC-1', + accessToken: 'token', + environment: 'live', + orderType: 'limit', + timeInForce: 'day', + limitPrice: 123.45, + }) + + expect(request.url).toContain('/accounts/ACC-1/orders') + expect(request.method).toBe('POST') + expect(request.headers['Content-Type']).toBe('application/x-www-form-urlencoded') + expect(request.body).toContain('class=equity') + expect(request.body).toContain('symbol=AAPL') + expect(request.body).toContain('side=sell') + expect(request.body).toContain('quantity=2') + expect(request.body).toContain('type=limit') + expect(request.body).toContain('duration=day') + expect(request.body).toContain('price=123.45') + }) +}) diff --git a/apps/tradinggoose/providers/trading/tradier/performance.ts b/apps/tradinggoose/providers/trading/tradier/performance.ts new file mode 100644 index 000000000..f11e74d2d --- /dev/null +++ b/apps/tradinggoose/providers/trading/tradier/performance.ts @@ -0,0 +1,147 @@ +import { + buildTradingPortfolioPerformance, + createUnavailableTradingPortfolioPerformance, + fetchBrokerJson, + toFiniteNumber, +} from '@/providers/trading/portfolio-utils' +import { tradierTradingProviderConfig } from '@/providers/trading/tradier/config' +import { buildTradierAuthHeaders, resolveTradierBaseUrl } from '@/providers/trading/tradier/client' +import type { + TradingPortfolioAccountContext, + TradingPortfolioPerformanceWindow, + UnifiedTradingPortfolioPerformance, + UnifiedTradingPortfolioPerformancePoint, +} from '@/providers/trading/types' + +const getTradierSupportedPerformanceWindows = () => + tradierTradingProviderConfig.capabilities?.holdings?.performanceWindows ?? [] + +type TradierTradingPortfolioPerformanceWindow = Exclude< + TradingPortfolioPerformanceWindow, + '1D' | '3M' +> + +const isTradierPerformanceWindowSupported = ( + window: TradingPortfolioPerformanceWindow +): window is TradierTradingPortfolioPerformanceWindow => + getTradierSupportedPerformanceWindows().includes(window) + +export const mapTradierPerformanceWindow = ( + window: TradierTradingPortfolioPerformanceWindow +): string => { + switch (window) { + case '1W': + return 'WEEK' + case '1M': + return 'MONTH' + case 'YTD': + return 'YTD' + case '1Y': + return 'YEAR' + case 'MAX': + return 'ALL' + } +} + +const normalizeTradierPerformanceDate = (value: string): string | null => { + const trimmed = value.trim() + if (!trimmed) return null + + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + return `${trimmed}T12:00:00.000Z` + } + + const parsed = Date.parse(trimmed) + if (!Number.isFinite(parsed)) { + return null + } + + return new Date(parsed).toISOString() +} + +const toTradierHistoryRows = (historyResponse: any) => { + const rows = + historyResponse?.history?.day ?? + historyResponse?.history?.row ?? + historyResponse?.history?.balance ?? + historyResponse?.history ?? + [] + + if (Array.isArray(rows)) return rows + if (!rows) return [] + return [rows] +} + +export const normalizeTradierHistoricalBalancesResponse = ({ + historyResponse, + window, +}: { + historyResponse: any + window: TradingPortfolioPerformanceWindow +}): UnifiedTradingPortfolioPerformance => { + const rows = toTradierHistoryRows(historyResponse) + const series: UnifiedTradingPortfolioPerformancePoint[] = [] + + for (const row of rows) { + const date = typeof row?.date === 'string' ? row.date.trim() : '' + const timestamp = normalizeTradierPerformanceDate(date) + const equity = toFiniteNumber(row?.value) + if (!timestamp || typeof equity !== 'number') { + continue + } + + series.push({ + timestamp, + equity, + }) + } + + series.sort((left, right) => left.timestamp.localeCompare(right.timestamp)) + + return buildTradingPortfolioPerformance({ + window, + supportedWindows: getTradierSupportedPerformanceWindows(), + series, + currency: 'USD', + unavailableReason: 'No usable performance data returned by broker', + }) +} + +export async function getTradierTradingAccountPerformance( + context: TradingPortfolioAccountContext & { window: TradingPortfolioPerformanceWindow } +): Promise<UnifiedTradingPortfolioPerformance> { + if (!isTradierPerformanceWindowSupported(context.window)) { + return createUnavailableTradingPortfolioPerformance({ + window: context.window, + supportedWindows: getTradierSupportedPerformanceWindows(), + unavailableReason: `Tradier performance window ${context.window} is not supported`, + }) + } + + if (context.environment === 'paper') { + return createUnavailableTradingPortfolioPerformance({ + window: context.window, + supportedWindows: getTradierSupportedPerformanceWindows(), + unavailableReason: 'Tradier paper performance is not implemented in portfolio_snapshot v1', + }) + } + + const baseUrl = resolveTradierBaseUrl(context.environment) + const period = mapTradierPerformanceWindow(context.window) + const historyResponse = await fetchBrokerJson<any>({ + providerId: context.providerId, + url: `${baseUrl}/accounts/${context.accountId}/historical-balances?period=${period}`, + init: { + method: 'GET', + headers: { + ...buildTradierAuthHeaders({ accessToken: context.accessToken }), + Accept: 'application/json', + }, + }, + }) + + return normalizeTradierHistoricalBalancesResponse({ + historyResponse, + window: context.window, + }) +} diff --git a/apps/tradinggoose/providers/trading/tradier/portfolio.test.ts b/apps/tradinggoose/providers/trading/tradier/portfolio.test.ts new file mode 100644 index 000000000..023dfb308 --- /dev/null +++ b/apps/tradinggoose/providers/trading/tradier/portfolio.test.ts @@ -0,0 +1,175 @@ +/** + * @vitest-environment node + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getTradingPortfolioSupportedWindows } from '@/providers/trading/portfolio' +import { normalizeTradierTradingAccount } from '@/providers/trading/tradier/accounts' +import { + getTradierTradingAccountPerformance, + mapTradierPerformanceWindow, + normalizeTradierHistoricalBalancesResponse, +} from '@/providers/trading/tradier/performance' +import { getTradierTradingAccountSnapshot } from '@/providers/trading/tradier/snapshot' + +describe('Tradier portfolio helpers', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('normalizes profile accounts for selector display', () => { + expect( + normalizeTradierTradingAccount({ + account_number: 'ACC-123', + classification: 'Individual', + type: 'margin', + status: 'active', + }) + ).toEqual({ + id: 'ACC-123', + name: 'Individual (ACC-123)', + type: 'margin', + baseCurrency: 'USD', + status: 'active', + }) + }) + + it('builds snapshot totals from balances and positions with the documented fallback ladder', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn> + + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + balances: { + account_number: 'ACC-123', + account_type: 'cash', + total_cash: '1200', + total_equity: '5400', + equity: '5400', + market_value: '4200', + open_pl: '250', + close_pl: '40', + current_requirement: '0', + }, + margin: { + stock_buying_power: '6000', + }, + }), + { status: 200 } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + positions: { + position: [ + { + symbol: 'MSFT', + quantity: '20', + market_value: '4200', + cost_basis: '3950', + date_acquired: '2026-01-10', + }, + ], + }, + }), + { status: 200 } + ) + ) + + const snapshot = await getTradierTradingAccountSnapshot({ + providerId: 'tradier', + environment: 'live', + accessToken: 'token', + accountId: 'ACC-123', + }) + + expect(snapshot.accountSummary).toMatchObject({ + totalCashValue: 1200, + totalHoldingsValue: 4200, + totalPortfolioValue: 5400, + equity: 5400, + buyingPower: 6000, + totalRealizedPnl: 40, + totalUnrealizedPnl: 250, + }) + expect(snapshot.positions).toHaveLength(1) + expect(snapshot.positions[0]?.symbol.listing).toEqual({ + listing_id: 'MSFT', + base_id: '', + quote_id: '', + listing_type: 'default', + }) + expect(snapshot.extra).toBeUndefined() + }) + + it('maps supported windows and normalizes Tradier history rows', () => { + expect(mapTradierPerformanceWindow('MAX')).toBe('ALL') + + const performance = normalizeTradierHistoricalBalancesResponse({ + historyResponse: { + history: { + day: [ + { date: '2026-04-22', value: '12000' }, + { date: '2026-04-20', value: '10000' }, + ], + }, + }, + window: '1M', + }) + + expect(performance.series.map((point) => point.timestamp)).toEqual([ + '2026-04-20T12:00:00.000Z', + '2026-04-22T12:00:00.000Z', + ]) + expect(performance.series.map((point) => point.equity)).toEqual([10000, 12000]) + expect(performance.summary).toMatchObject({ + currency: 'USD', + startEquity: 10000, + endEquity: 12000, + absoluteReturn: 2000, + percentReturn: 20, + }) + }) + + it('returns an explicit unavailable payload for Tradier paper performance in v1', async () => { + const performance = await getTradierTradingAccountPerformance({ + providerId: 'tradier', + environment: 'paper', + accessToken: 'token', + accountId: 'ACC-123', + window: '1M', + }) + + expect(performance.summary).toBeNull() + expect(performance.series).toEqual([]) + expect(performance.unavailableReason).toBe( + 'Tradier paper performance is not implemented in portfolio_snapshot v1' + ) + }) + + it('returns an explicit unavailable payload for unsupported Tradier windows', async () => { + const performance = await getTradierTradingAccountPerformance({ + providerId: 'tradier', + environment: 'live', + accessToken: 'token', + accountId: 'ACC-123', + window: '3M', + }) + + expect(performance).toEqual({ + window: '3M', + supportedWindows: getTradingPortfolioSupportedWindows('tradier'), + series: [], + summary: null, + unavailableReason: 'Tradier performance window 3M is not supported', + }) + expect(global.fetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/providers/trading/tradier/positions.ts b/apps/tradinggoose/providers/trading/tradier/positions.ts index 9ac7379a0..afe32f7da 100644 --- a/apps/tradinggoose/providers/trading/tradier/positions.ts +++ b/apps/tradinggoose/providers/trading/tradier/positions.ts @@ -1,3 +1,4 @@ +import { sumFiniteNumbers, toFiniteNumber } from '@/providers/trading/portfolio-utils' import { buildTradierAuthHeaders, resolveTradierBaseUrl } from '@/providers/trading/tradier/client' import { tradierTradingProviderConfig } from '@/providers/trading/tradier/config' import type { @@ -10,17 +11,9 @@ import type { } from '@/providers/trading/types' import { tradingSymbolToListingIdentity } from '@/providers/trading/utils' -const DEFAULT_BASE_CURRENCY = 'USD' +export const TRADIER_DEFAULT_BASE_CURRENCY = 'USD' -const toNumber = (value: unknown): number | undefined => { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : undefined -} - -const sumNumbers = (values: Array<number | undefined>): number => - values.reduce<number>((total, value) => (typeof value === 'number' ? total + value : total), 0) - -const getCurrencySymbol = (currency?: string) => { +export const getTradierCurrencySymbol = (currency?: string) => { switch (currency) { case 'USD': return '$' @@ -35,7 +28,7 @@ const getCurrencySymbol = (currency?: string) => { } } -const mapAccountType = (value: unknown): UnifiedTradingAccountType => { +export const mapTradierAccountType = (value: unknown): UnifiedTradingAccountType => { if (typeof value !== 'string') return 'unknown' const normalized = value.toLowerCase() if (normalized === 'margin') return 'margin' @@ -43,14 +36,14 @@ const mapAccountType = (value: unknown): UnifiedTradingAccountType => { return 'unknown' } -const extractPositions = (data: any) => { +export const extractTradierPositions = (data: any) => { const positions = data?.positions?.position || data?.positions || data?.position || [] if (Array.isArray(positions)) return positions if (!positions) return [] return [positions] } -const extractBalances = (data: any) => { +export const extractTradierBalances = (data: any) => { if (!data || typeof data !== 'object') return undefined if (data.balance && typeof data.balance === 'object') { return { @@ -80,44 +73,18 @@ const extractBalances = (data: any) => { return undefined } -export const buildTradierHoldingsRequest = (params: TradingHoldingsInput): TradingRequestConfig => { - if (!params.accountId) { - throw new Error('Tradier account ID is required') - } - - const authHeaders = buildTradierAuthHeaders(params) - const baseUrl = resolveTradierBaseUrl(params.environment) - const resource = params.providerParams?.resource === 'balances' ? 'balances' : 'positions' +export const normalizeTradierPositions = (positions: unknown): UnifiedTradingPosition[] => { + const list = Array.isArray(positions) ? positions : [] - return { - url: `${baseUrl}/accounts/${params.accountId}/${resource}`, - method: 'GET', - headers: { - ...authHeaders, - Accept: 'application/json', - }, - } -} - -export const normalizeTradierHoldings = ( - data: any, - context?: TradingHoldingsNormalizationContext -): UnifiedTradingAccountSnapshot => { - const list = extractPositions(data) - const balancePayload = extractBalances(data) - const balances = balancePayload?.balances - const margin = balancePayload?.margin - const cash = balancePayload?.cash - - const normalizedPositions: UnifiedTradingPosition[] = list.map((position: any) => { + return list.map((position: any) => { const resolvedSymbol = tradingSymbolToListingIdentity(tradierTradingProviderConfig, { symbol: typeof position?.symbol === 'string' ? position.symbol : undefined, assetClass: 'stock', - defaultQuote: DEFAULT_BASE_CURRENCY, + defaultQuote: TRADIER_DEFAULT_BASE_CURRENCY, }) - const quantity = toNumber(position?.quantity) ?? 0 - const marketValue = toNumber(position?.market_value) - const costBasis = toNumber(position?.cost_basis) + const quantity = toFiniteNumber(position?.quantity) ?? 0 + const marketValue = toFiniteNumber(position?.market_value) + const costBasis = toFiniteNumber(position?.cost_basis) const averagePrice = typeof costBasis === 'number' && quantity !== 0 ? Math.abs(costBasis / quantity) : undefined const side = quantity === 0 ? 'flat' : quantity < 0 ? 'short' : 'long' @@ -127,7 +94,7 @@ export const normalizeTradierHoldings = ( return { symbol: { base: resolvedSymbol?.base ?? 'UNKNOWN', - quote: resolvedSymbol?.quote ?? DEFAULT_BASE_CURRENCY, + quote: resolvedSymbol?.quote ?? TRADIER_DEFAULT_BASE_CURRENCY, listing: resolvedSymbol?.listing, name: null, assetClass: resolvedSymbol?.assetClass ?? 'stock', @@ -138,42 +105,80 @@ export const normalizeTradierHoldings = ( side, averagePrice, marketValue, - currencySymbol: getCurrencySymbol(DEFAULT_BASE_CURRENCY), + currencySymbol: getTradierCurrencySymbol(TRADIER_DEFAULT_BASE_CURRENCY), conversionRate: 1, costBasis, openedAt, } }) +} - const totalHoldingsValueFromPositions = sumNumbers( - normalizedPositions.map((position) => position.marketValue) - ) - const totalCostBasis = sumNumbers(normalizedPositions.map((position) => position.costBasis)) +export const sumTradierPositionMarketValues = (positions: UnifiedTradingPosition[]) => + sumFiniteNumbers(positions.map((position) => position.marketValue)) + +export const sumTradierPositionCostBasis = (positions: UnifiedTradingPosition[]) => + sumFiniteNumbers(positions.map((position) => position.costBasis)) + +export const sumTradierPositionUnrealizedPnl = (positions: UnifiedTradingPosition[]) => + sumFiniteNumbers(positions.map((position) => position.unrealizedPnl)) + +export const buildTradierHoldingsRequest = (params: TradingHoldingsInput): TradingRequestConfig => { + if (!params.accountId) { + throw new Error('Tradier account ID is required') + } + + const authHeaders = buildTradierAuthHeaders(params) + const baseUrl = resolveTradierBaseUrl(params.environment) + const resource = params.providerParams?.resource === 'balances' ? 'balances' : 'positions' + + return { + url: `${baseUrl}/accounts/${params.accountId}/${resource}`, + method: 'GET', + headers: { + ...authHeaders, + Accept: 'application/json', + }, + } +} + +export const normalizeTradierHoldings = ( + data: any, + context?: TradingHoldingsNormalizationContext +): UnifiedTradingAccountSnapshot => { + const list = extractTradierPositions(data) + const balancePayload = extractTradierBalances(data) + const balances = balancePayload?.balances + const margin = balancePayload?.margin + const cash = balancePayload?.cash + const normalizedPositions = normalizeTradierPositions(list) + + const totalHoldingsValueFromPositions = sumTradierPositionMarketValues(normalizedPositions) + const totalCostBasis = sumTradierPositionCostBasis(normalizedPositions) const hasMarketValues = normalizedPositions.some( (position) => typeof position.marketValue === 'number' ) const totalHoldingsValueFromBalances = - toNumber(balances?.market_value) ?? toNumber(balances?.long_market_value) + toFiniteNumber(balances?.market_value) ?? toFiniteNumber(balances?.long_market_value) const totalHoldingsValue = totalHoldingsValueFromBalances ?? (hasMarketValues ? totalHoldingsValueFromPositions : totalCostBasis) - const totalUnrealizedPnlFromPositions = sumNumbers( - normalizedPositions.map((position) => position.unrealizedPnl) - ) - const totalUnrealizedPnl = toNumber(balances?.open_pl) ?? totalUnrealizedPnlFromPositions - const totalRealizedPnl = toNumber(balances?.close_pl) - const totalCashValue = toNumber(balances?.total_cash) ?? toNumber(cash?.cash_available) ?? 0 + const totalUnrealizedPnl = + toFiniteNumber(balances?.open_pl) ?? sumTradierPositionUnrealizedPnl(normalizedPositions) + const totalRealizedPnl = toFiniteNumber(balances?.close_pl) + const totalCashValue = + toFiniteNumber(balances?.total_cash) ?? toFiniteNumber(cash?.cash_available) ?? 0 const totalPortfolioValue = - toNumber(balances?.total_equity) ?? totalHoldingsValue + totalCashValue + toFiniteNumber(balances?.total_equity) ?? totalHoldingsValue + totalCashValue const cashBalances = - toNumber(balances?.total_cash) !== undefined || toNumber(cash?.cash_available) !== undefined + toFiniteNumber(balances?.total_cash) !== undefined || + toFiniteNumber(cash?.cash_available) !== undefined ? [ { - currency: DEFAULT_BASE_CURRENCY, - currencySymbol: getCurrencySymbol(DEFAULT_BASE_CURRENCY), + currency: TRADIER_DEFAULT_BASE_CURRENCY, + currencySymbol: getTradierCurrencySymbol(TRADIER_DEFAULT_BASE_CURRENCY), amount: totalCashValue, conversionRate: 1, amountInAccountCurrency: totalCashValue, @@ -189,8 +194,8 @@ export const normalizeTradierHoldings = ( }, account: { id: balances?.account_number || context?.accountId || 'unknown', - type: mapAccountType(balances?.account_type), - baseCurrency: DEFAULT_BASE_CURRENCY, + type: mapTradierAccountType(balances?.account_type), + baseCurrency: TRADIER_DEFAULT_BASE_CURRENCY, status: 'unknown', }, cashBalances, @@ -202,9 +207,10 @@ export const normalizeTradierHoldings = ( totalHoldingsValue, totalUnrealizedPnl, totalRealizedPnl, - marginUsed: toNumber(balances?.current_requirement), - buyingPower: toNumber(margin?.stock_buying_power) ?? toNumber(balances?.stock_buying_power), - equity: toNumber(balances?.equity) ?? totalPortfolioValue, + marginUsed: toFiniteNumber(balances?.current_requirement), + buyingPower: + toFiniteNumber(margin?.stock_buying_power) ?? toFiniteNumber(balances?.stock_buying_power), + equity: toFiniteNumber(balances?.equity) ?? totalPortfolioValue, }, extra: { rawPositions: list, diff --git a/apps/tradinggoose/providers/trading/tradier/snapshot.ts b/apps/tradinggoose/providers/trading/tradier/snapshot.ts new file mode 100644 index 000000000..a6c9d4725 --- /dev/null +++ b/apps/tradinggoose/providers/trading/tradier/snapshot.ts @@ -0,0 +1,118 @@ +import { fetchBrokerJson, toFiniteNumber } from '@/providers/trading/portfolio-utils' +import { buildTradierAuthHeaders, resolveTradierBaseUrl } from '@/providers/trading/tradier/client' +import { + extractTradierBalances, + extractTradierPositions, + getTradierCurrencySymbol, + mapTradierAccountType, + normalizeTradierPositions, + sumTradierPositionCostBasis, + sumTradierPositionMarketValues, + sumTradierPositionUnrealizedPnl, + TRADIER_DEFAULT_BASE_CURRENCY, +} from '@/providers/trading/tradier/positions' +import type { + TradingPortfolioAccountContext, + UnifiedTradingAccountSnapshot, +} from '@/providers/trading/types' + +async function fetchTradierBalances(context: TradingPortfolioAccountContext) { + const baseUrl = resolveTradierBaseUrl(context.environment) + return fetchBrokerJson<any>({ + providerId: context.providerId, + url: `${baseUrl}/accounts/${context.accountId}/balances`, + init: { + method: 'GET', + headers: { + ...buildTradierAuthHeaders({ accessToken: context.accessToken }), + Accept: 'application/json', + }, + }, + }) +} + +async function fetchTradierPositions(context: TradingPortfolioAccountContext) { + const baseUrl = resolveTradierBaseUrl(context.environment) + return fetchBrokerJson<any>({ + providerId: context.providerId, + url: `${baseUrl}/accounts/${context.accountId}/positions`, + init: { + method: 'GET', + headers: { + ...buildTradierAuthHeaders({ accessToken: context.accessToken }), + Accept: 'application/json', + }, + }, + }) +} + +export async function getTradierTradingAccountSnapshot( + context: TradingPortfolioAccountContext +): Promise<UnifiedTradingAccountSnapshot> { + const [balancesResponse, positionsResponse] = await Promise.all([ + fetchTradierBalances(context), + fetchTradierPositions(context), + ]) + + const rawPositions = extractTradierPositions(positionsResponse) + const balancePayload = extractTradierBalances(balancesResponse) + const balances = balancePayload?.balances + const margin = balancePayload?.margin + const cash = balancePayload?.cash + const positions = normalizeTradierPositions(rawPositions) + + const positionMarketValues = sumTradierPositionMarketValues(positions) + const positionCostBasis = sumTradierPositionCostBasis(positions) + const totalHoldingsValue = + toFiniteNumber(balances?.market_value) ?? + toFiniteNumber(balances?.long_market_value) ?? + (positions.some((position) => typeof position.marketValue === 'number') + ? positionMarketValues + : positionCostBasis) + + const totalCashValue = + toFiniteNumber(balances?.total_cash) ?? toFiniteNumber(cash?.cash_available) ?? 0 + const totalPortfolioValue = + toFiniteNumber(balances?.total_equity) ?? totalHoldingsValue + totalCashValue + const equity = toFiniteNumber(balances?.equity) ?? totalPortfolioValue + const totalUnrealizedPnl = + toFiniteNumber(balances?.open_pl) ?? sumTradierPositionUnrealizedPnl(positions) + + return { + asOf: new Date().toISOString(), + provider: { + name: 'Tradier', + environment: context.environment ?? 'unknown', + }, + account: { + id: + (typeof balances?.account_number === 'string' && balances.account_number.trim()) || + context.accountId, + type: mapTradierAccountType(balances?.account_type), + baseCurrency: TRADIER_DEFAULT_BASE_CURRENCY, + status: 'unknown', + }, + cashBalances: [ + { + currency: TRADIER_DEFAULT_BASE_CURRENCY, + currencySymbol: getTradierCurrencySymbol(TRADIER_DEFAULT_BASE_CURRENCY), + amount: totalCashValue, + conversionRate: 1, + amountInAccountCurrency: totalCashValue, + }, + ], + positions, + orders: [], + accountSummary: { + totalCashValue, + totalHoldingsValue, + totalPortfolioValue, + equity, + buyingPower: + toFiniteNumber(margin?.stock_buying_power) ?? toFiniteNumber(balances?.stock_buying_power), + marginUsed: toFiniteNumber(balances?.current_requirement), + totalRealizedPnl: toFiniteNumber(balances?.close_pl), + totalUnrealizedPnl, + }, + } +} diff --git a/apps/tradinggoose/providers/trading/types.ts b/apps/tradinggoose/providers/trading/types.ts index c315dcf91..024c22849 100644 --- a/apps/tradinggoose/providers/trading/types.ts +++ b/apps/tradinggoose/providers/trading/types.ts @@ -5,7 +5,7 @@ import type { HttpMethod } from '@/tools/types' export type TradingProviderId = 'alpaca' | 'tradier' | (string & {}) -export type TradingAuthType = 'apiKey' | 'oauth' +export type TradingAuthType = 'oauth' export type TradingOrderType = | 'market' @@ -61,8 +61,6 @@ export interface TradingOrderInput extends TradingSymbolInput { trailPercent?: number environment?: 'paper' | 'live' accessToken?: string - apiKey?: string - apiSecret?: string orderClass?: string accountId?: string providerParams?: TradingProviderParams @@ -71,8 +69,6 @@ export interface TradingOrderInput extends TradingSymbolInput { export interface TradingHoldingsInput { environment?: 'paper' | 'live' accessToken?: string - apiKey?: string - apiSecret?: string accountId?: string providerParams?: TradingProviderParams } @@ -102,6 +98,16 @@ export interface TradingHoldingsNormalizationContext extends TradingHoldingsInpu providerName?: string } +export interface TradingPortfolioBaseContext { + providerId: TradingProviderId + environment?: 'paper' | 'live' + accessToken: string +} + +export interface TradingPortfolioAccountContext extends TradingPortfolioBaseContext { + accountId: string +} + export interface TradingOrderRequest extends TradingOrderInput { kind: 'order' } @@ -113,8 +119,6 @@ export interface TradingHoldingsRequest extends TradingHoldingsInput { export type TradingProviderRequest = TradingOrderRequest | TradingHoldingsRequest export interface TradingProviderParams { - apiKey?: string - apiSecret?: string accessToken?: string [key: string]: any } @@ -185,6 +189,16 @@ export interface UnifiedTradingPosition { updatedAt?: string } +export interface UnifiedTradingPositionListing { + listing: ListingIdentity + grossQuantity: number + signedQuantity: number +} + +export interface UnifiedTradingPositionListings { + positionListings: UnifiedTradingPositionListing[] +} + export type UnifiedTradingOrderType = | 'Market' | 'Limit' @@ -254,6 +268,32 @@ export interface UnifiedTradingAccountSnapshot { extra?: Record<string, any> } +export type TradingPortfolioPerformanceWindow = '1D' | '1W' | '1M' | '3M' | 'YTD' | '1Y' | 'MAX' + +export interface UnifiedTradingPortfolioPerformancePoint { + timestamp: string + equity: number +} + +export interface UnifiedTradingPortfolioPerformanceSummary { + currency: string + startEquity: number + endEquity: number + highEquity: number + lowEquity: number + absoluteReturn: number + percentReturn: number | null + asOf: string +} + +export interface UnifiedTradingPortfolioPerformance { + window: TradingPortfolioPerformanceWindow + supportedWindows: TradingPortfolioPerformanceWindow[] + series: UnifiedTradingPortfolioPerformancePoint[] + summary: UnifiedTradingPortfolioPerformanceSummary | null + unavailableReason?: string +} + export interface TradingOrder { id?: string status?: string @@ -269,6 +309,10 @@ export type TradingProviderResponse = TradingOrder | UnifiedTradingAccountSnapsh export interface TradingProviderOAuthConfig { provider: OAuthService serviceId?: OAuthService + credentialServices?: Array<{ + serviceId: OAuthService + environment?: 'paper' | 'live' + }> scopes?: string[] credentialTitle?: string credentialPlaceholder?: string diff --git a/apps/tradinggoose/providers/trading/utils.test.ts b/apps/tradinggoose/providers/trading/utils.test.ts index 0ef2b5fc1..b0db8c65b 100644 --- a/apps/tradinggoose/providers/trading/utils.test.ts +++ b/apps/tradinggoose/providers/trading/utils.test.ts @@ -1,125 +1,207 @@ import { describe, expect, it } from 'vitest' -import { normalizeAlpacaHoldings } from '@/providers/trading/alpaca/positions' +import type { ListingResolved } from '@/lib/listing/identity' import { alpacaTradingProviderConfig } from '@/providers/trading/alpaca/config' -import { normalizeTradierHoldings } from '@/providers/trading/tradier/positions' -import { tradierTradingProviderConfig } from '@/providers/trading/tradier/config' import { + isTradingOrderListingSupported, listingIdentityToTradingSymbol, + resolveTradingListingAssetClass, tradingSymbolToListingIdentity, } from '@/providers/trading/utils' -describe('listingIdentityToTradingSymbol', () => { - it('maps default listing identities to stock provider symbols', () => { - const symbol = listingIdentityToTradingSymbol(tradierTradingProviderConfig, { - listing: { - listing_id: 'SPY', - base_id: '', - quote_id: '', - listing_type: 'default', - }, - }) +const stockListing: ListingResolved = { + listing_type: 'default', + listing_id: 'AAPL', + base_id: '', + quote_id: '', + base: 'AAPL', + quote: 'USD', + assetClass: 'stock', +} - expect(symbol).toBe('SPY') - }) +const etfListing: ListingResolved = { + listing_type: 'default', + listing_id: 'SPY', + base_id: '', + quote_id: '', + base: 'SPY', + quote: 'USD', + assetClass: 'etf', +} - it('maps crypto listing identities to provider symbols without provider-local wrappers', () => { - const symbol = listingIdentityToTradingSymbol(alpacaTradingProviderConfig, { - listing: { +const assetlessListing: ListingResolved = { + listing_type: 'default', + listing_id: 'AAPL', + base_id: '', + quote_id: '', + base: 'AAPL', + quote: 'USD', +} + +describe('trading listing utility helpers', () => { + it('resolves asset class from enriched listing fields without legacy equity mapping', () => { + expect(resolveTradingListingAssetClass(assetlessListing)).toBeUndefined() + expect(resolveTradingListingAssetClass(stockListing)).toBe('stock') + expect(resolveTradingListingAssetClass(stockListing, 'etf')).toBe('etf') + expect( + resolveTradingListingAssetClass({ + listing_type: 'crypto', listing_id: '', base_id: 'BTC', quote_id: 'USD', - listing_type: 'crypto', - }, - }) + }) + ).toBe('crypto') + expect( + resolveTradingListingAssetClass({ + listing_type: 'currency', + listing_id: '', + base_id: 'EUR', + quote_id: 'USD', + }) + ).toBe('currency') + expect( + resolveTradingListingAssetClass({ + listing_type: 'default', + listing_id: 'ES', + base_asset_class: 'future', + } as any) + ).toBe('future') + expect( + resolveTradingListingAssetClass({ + listing_type: 'default', + listing_id: 'SPX', + assetClass: 'indice', + } as any) + ).toBe('indice') + expect( + resolveTradingListingAssetClass({ + listing_type: 'default', + listing_id: 'VTSAX', + assetClass: 'mutualfund', + } as any) + ).toBe('mutualfund') + expect( + resolveTradingListingAssetClass({ + listing_type: 'default', + listing_id: 'SPX', + assetClass: 'us_equity', + } as any) + ).toBeUndefined() + expect(resolveTradingListingAssetClass({ listing_type: 'equity' } as any)).toBeUndefined() + }) - expect(symbol).toBe('BTC/USD') + it('validates provider support only after an asset class can be resolved', () => { + expect(isTradingOrderListingSupported('alpaca', stockListing)).toBe(true) + expect(isTradingOrderListingSupported('alpaca', etfListing)).toBe(false) + expect(isTradingOrderListingSupported('alpaca', assetlessListing)).toBe(true) + expect( + isTradingOrderListingSupported('alpaca', { + listing_type: 'crypto', + listing_id: '', + base_id: 'BTC', + quote_id: 'USD', + }) + ).toBe(true) + expect( + isTradingOrderListingSupported('tradier', { + listing_type: 'currency', + listing_id: '', + base_id: 'EUR', + quote_id: 'USD', + }) + ).toBe(false) + expect( + isTradingOrderListingSupported('tradier', { + listing_type: 'default', + listing_id: 'ES', + assetClass: 'future', + } as any) + ).toBe(false) }) -}) -describe('tradingSymbolToListingIdentity', () => { - it('maps provider crypto symbols back to listing identities', () => { + it('keeps generic symbol conversion independent from quick-order asset-class strictness', () => { + expect( + listingIdentityToTradingSymbol(alpacaTradingProviderConfig, { + listing: { + listing_type: 'default', + listing_id: 'AAPL', + base_id: '', + quote_id: '', + }, + }) + ).toBe('AAPL') + + expect( + listingIdentityToTradingSymbol(alpacaTradingProviderConfig, { + listing: { + listing_type: 'crypto', + listing_id: '', + base_id: 'BTC', + quote_id: 'USD', + }, + assetClass: 'crypto', + }) + ).toBe('BTC/USD') + expect( tradingSymbolToListingIdentity(alpacaTradingProviderConfig, { symbol: 'BTC/USD', assetClass: 'crypto', }) ).toMatchObject({ - base: 'BTC', - quote: 'USD', - assetClass: 'crypto', listing: { - listing_id: '', + listing_type: 'crypto', base_id: 'BTC', quote_id: 'USD', - listing_type: 'crypto', }, + assetClass: 'crypto', }) - }) - it('maps provider stock symbols back to default listing identities', () => { expect( - tradingSymbolToListingIdentity(tradierTradingProviderConfig, { - symbol: 'AAPL', + tradingSymbolToListingIdentity(alpacaTradingProviderConfig, { + symbol: 'DOGEUSD', + assetClass: 'crypto', }) ).toMatchObject({ - base: 'AAPL', - quote: 'USD', - assetClass: 'stock', listing: { - listing_id: 'AAPL', - base_id: '', - quote_id: '', - listing_type: 'default', - }, - }) - }) -}) - -describe('provider holdings normalization', () => { - it('preserves the canonical listing identity for Alpaca positions', () => { - const snapshot = normalizeAlpacaHoldings([ - { - symbol: 'BTC/USD', - asset_class: 'crypto', - qty: '1.5', - side: 'long', + listing_type: 'crypto', + base_id: 'DOGE', + quote_id: 'USD', }, - ]) - - expect(snapshot.positions[0]?.symbol).toMatchObject({ - base: 'BTC', + base: 'DOGE', quote: 'USD', assetClass: 'crypto', - listing: { - listing_id: '', - base_id: 'BTC', - quote_id: 'USD', - listing_type: 'crypto', - }, }) - }) - it('preserves the canonical listing identity for Tradier positions', () => { - const snapshot = normalizeTradierHoldings({ - positions: { - position: { - symbol: 'SPY', - quantity: '2', - cost_basis: '1000', - }, + expect( + tradingSymbolToListingIdentity(alpacaTradingProviderConfig, { + symbol: 'BTCUSDT', + assetClass: 'crypto', + }) + ).toMatchObject({ + listing: { + listing_type: 'crypto', + base_id: 'BTC', + quote_id: 'USDT', }, + base: 'BTC', + quote: 'USDT', + assetClass: 'crypto', }) - expect(snapshot.positions[0]?.symbol).toMatchObject({ - base: 'SPY', - quote: 'USD', - assetClass: 'stock', + expect( + tradingSymbolToListingIdentity(alpacaTradingProviderConfig, { + symbol: 'SOLUSD', + assetClass: 'crypto', + }) + ).toMatchObject({ listing: { - listing_id: 'SPY', - base_id: '', - quote_id: '', - listing_type: 'default', + listing_type: 'crypto', + base_id: 'SOL', + quote_id: 'USD', }, + base: 'SOL', + quote: 'USD', + assetClass: 'crypto', }) }) }) diff --git a/apps/tradinggoose/providers/trading/utils.ts b/apps/tradinggoose/providers/trading/utils.ts index 5a8bced98..1f4bca0e3 100644 --- a/apps/tradinggoose/providers/trading/utils.ts +++ b/apps/tradinggoose/providers/trading/utils.ts @@ -6,8 +6,19 @@ import type { TradingRuleScopeKey, TradingSymbolRule, } from '@/providers/trading/providers' +import { getTradingProviderConfig } from '@/providers/trading/providers' import type { TradingSymbolInput } from '@/providers/trading/types' +const TRADING_ASSET_CLASS_SET = new Set<AssetClass>([ + 'stock', + 'etf', + 'future', + 'currency', + 'crypto', + 'indice', + 'mutualfund', +]) + const readListingField = (record: Record<string, unknown>, key: string): string | undefined => { const value = record[key] if (typeof value === 'string' && value.trim()) { @@ -44,6 +55,45 @@ export interface TradingSymbolToListingIdentityResult { assetClass: AssetClass } +const normalizeTradingListingAssetClass = (value: unknown): AssetClass | undefined => { + if (typeof value !== 'string') return undefined + const normalized = value.trim().toLowerCase() + return TRADING_ASSET_CLASS_SET.has(normalized as AssetClass) + ? (normalized as AssetClass) + : undefined +} + +export function resolveTradingListingAssetClass( + listing?: ListingInputValue | null, + explicitAssetClass?: AssetClass | null +): AssetClass | undefined { + const listingIdentity = toListingValueObject(listing) + const record = (listing || {}) as Record<string, unknown> + const listingType = typeof record.listing_type === 'string' ? record.listing_type : undefined + + return ( + normalizeTradingListingAssetClass(explicitAssetClass) || + normalizeTradingListingAssetClass(record.assetClass) || + normalizeTradingListingAssetClass(record.base_asset_class) || + normalizeTradingListingAssetClass(record.quote_asset_class) || + inferAssetClassFromListing(listingIdentity) || + (listingType === 'crypto' || listingType === 'currency' + ? (listingType as AssetClass) + : undefined) + ) +} + +export function isTradingOrderListingSupported( + providerId: string, + listing?: ListingInputValue | null +): boolean { + const assetClass = resolveTradingListingAssetClass(listing) + if (!assetClass) return true + + const supportedAssetClasses = getTradingProviderConfig(providerId)?.availability.assetClass ?? [] + return supportedAssetClasses.includes(assetClass) +} + function buildTradingListingContext(input: TradingSymbolInput): TradingListingContext { const listingValue = input.listing as ListingInputValue | undefined const record = (listingValue || {}) as Record<string, unknown> @@ -125,17 +175,24 @@ export function tradingSymbolToListingIdentity( ) .sort((left, right) => { const scoreDelta = - scoreReverseRule(right.rule, input.assetClass) - scoreReverseRule(left.rule, input.assetClass) + scoreReverseRule(right.rule, input.assetClass) - + scoreReverseRule(left.rule, input.assetClass) return scoreDelta !== 0 ? scoreDelta : left.index - right.index })[0]?.rule - const parsed = matchedRule + const parsedSymbol = matchedRule ? parseSymbolWithTemplate(symbol, matchedRule.template) : parseDefaultTradingSymbol(symbol) - if (!parsed) return null + if (!parsedSymbol) return null const assetClass = matchedRule?.assetClass ?? input.assetClass ?? fallbackAssetClass const listingType = toListingType(assetClass) + const parsed = resolveCompactPairSymbol({ + config, + parsed: parsedSymbol, + listingType, + defaultQuote, + }) const base = normalizeTradingProviderSymbol(parsed.base ?? parsed.listing) const quote = normalizeTradingProviderSymbol(parsed.quote) ?? @@ -339,10 +396,7 @@ function scoreReverseRule(rule: TradingSymbolRule, assetClass?: AssetClass | nul return score } -function parseSymbolWithTemplate( - symbol: string, - template: string -): Record<string, string> | null { +function parseSymbolWithTemplate(symbol: string, template: string): Record<string, string> | null { const pattern = buildTemplateRegex(template) if (!pattern) return null const match = pattern.exec(symbol) @@ -385,13 +439,6 @@ function resolveTemplateCapture(key: string): string { return '(?<exchangeSuffix>\\.[A-Za-z0-9._-]+)' case 'exchangeCode': return '(?<exchangeCode>[A-Za-z0-9._-]+)' - case 'base': - case 'quote': - case 'listing': - case 'market': - case 'country': - case 'city': - case 'assetClass': default: return `(?<${key}>[A-Za-z0-9._:-]+)` } @@ -415,6 +462,93 @@ function parseDefaultTradingSymbol(symbol: string): Record<string, string> { } } +function resolveCompactPairSymbol({ + config, + parsed, + listingType, + defaultQuote, +}: { + config: TradingProviderConfig + parsed: Record<string, string> + listingType: ListingType + defaultQuote: string +}): Record<string, string> { + if (listingType === 'default' || parsed.quote) return parsed + + const base = normalizeTradingProviderSymbol(parsed.base ?? parsed.listing) + if (!base) return parsed + + const split = splitCompactPairSymbol(config, listingType, base, defaultQuote) + return split ? { ...parsed, ...split } : parsed +} + +function splitCompactPairSymbol( + config: TradingProviderConfig, + listingType: ListingType, + symbol: string, + defaultQuote: string +): { base: string; quote: string } | null { + const normalizedSymbol = normalizeTradingProviderSymbol(symbol) + if (!normalizedSymbol) return null + + const quoteCandidates = getPairQuoteCandidates(config, listingType, defaultQuote) + if (!quoteCandidates.length) return null + + const upperSymbol = normalizedSymbol.toUpperCase() + + for (const quote of quoteCandidates) { + const upperQuote = quote.toUpperCase() + if (upperSymbol === upperQuote || !upperSymbol.endsWith(upperQuote)) continue + + const base = normalizedSymbol.slice(0, normalizedSymbol.length - quote.length).trim() + if (base.length < 2) continue + + return { + base, + quote, + } + } + + return null +} + +function getPairQuoteCandidates( + config: TradingProviderConfig, + listingType: ListingType, + defaultQuote: string +): string[] { + if (listingType === 'default') return [] + + const availability = + listingType === 'crypto' + ? config.availability.availableCryptoQuote + : config.availability.availableCurrencyQuote + const ruleQuotes = config.rules + .filter((rule) => rule.active !== false) + .filter((rule) => toListingType(rule.assetClass) === listingType) + .map((rule) => rule.currency) + + return uniqueSymbols([defaultQuote, ...(availability ?? []), ...ruleQuotes]).sort( + (left, right) => right.length - left.length + ) +} + +function uniqueSymbols(values: Array<string | null | undefined>): string[] { + const seen = new Set<string>() + const result: string[] = [] + + for (const value of values) { + const normalized = normalizeTradingProviderSymbol(value) + if (!normalized) continue + const key = normalized.toUpperCase() + if (seen.has(key)) continue + seen.add(key) + result.push(normalized) + } + + return result +} + function toListingType(assetClass?: AssetClass | null): ListingType { if (assetClass === 'crypto') return 'crypto' if (assetClass === 'currency') return 'currency' diff --git a/apps/tradinggoose/proxy.test.ts b/apps/tradinggoose/proxy.test.ts index e3bce7db4..13657d1f0 100644 --- a/apps/tradinggoose/proxy.test.ts +++ b/apps/tradinggoose/proxy.test.ts @@ -1,7 +1,18 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { NextRequest } from 'next/server' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' import { beforeEach, describe, expect, it, vi } from 'vitest' const mockGetSessionCookie = vi.fn() +const browserHeaders = { + 'user-agent': 'Mozilla/5.0', +} + +function createRequest(url: string) { + return new NextRequest(url, { headers: browserHeaders }) +} vi.mock('better-auth/cookies', () => ({ getSessionCookie: (...args: unknown[]) => mockGetSessionCookie(...args), @@ -22,6 +33,7 @@ describe('proxy auth routing', () => { vi.clearAllMocks() vi.resetModules() process.env.NEXT_PUBLIC_APP_URL = 'https://www.tradinggoose.ai' + mockGetSessionCookie.mockReturnValue(undefined) }) it('uses the request host for localhost auth redirects instead of hosted-mode rewrites', async () => { @@ -29,7 +41,7 @@ describe('proxy auth routing', () => { const { proxy } = await import('./proxy') const response = await proxy( - new NextRequest('http://localhost:3000/workspace/ws-1/dashboard?layoutId=layout-1') + createRequest('http://localhost:3000/workspace/ws-1/dashboard?layoutId=layout-1') ) expect(response.status).toBe(307) @@ -42,14 +54,124 @@ describe('proxy auth routing', () => { it('redirects hosted protected routes to login when no session is present', async () => { const { proxy } = await import('./proxy') const response = await proxy( - new NextRequest('https://www.tradinggoose.ai/workspace/ws-1/dashboard') + createRequest('https://www.tradinggoose.ai/workspace/ws-1/dashboard') + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://www.tradinggoose.ai/login?callbackUrl=%2Fworkspace%2Fws-1%2Fdashboard' + ) + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + }) + + it('canonicalizes callbacks for default-locale protected routes', async () => { + const { proxy } = await import('./proxy') + const response = await proxy( + createRequest('https://www.tradinggoose.ai/en/workspace/ws-1/dashboard') ) expect(response.status).toBe(307) expect(response.headers.get('location')).toBe( 'https://www.tradinggoose.ai/login?callbackUrl=%2Fworkspace%2Fws-1%2Fdashboard' ) + }) + + it('rewrites locale-prefixed homepage requests to the root route', async () => { + const { proxy } = await import('./proxy') + const response = await proxy(createRequest('https://www.tradinggoose.ai/es')) + + expect(response.status).toBe(200) + expect(response.headers.get('x-middleware-rewrite')).toBe('https://www.tradinggoose.ai/') + expect(response.headers.get('location')).toBeNull() + }) + + it('rewrites locale-prefixed blog requests to the unprefixed route', async () => { + const { proxy } = await import('./proxy') + const response = await proxy(createRequest('https://www.tradinggoose.ai/es/blog')) + + expect(response.status).toBe(200) + expect(response.headers.get('x-middleware-rewrite')).toBe('https://www.tradinggoose.ai/blog') + expect(response.headers.get('location')).toBeNull() + }) + + it('redirects locale-prefixed English-only legal pages to the canonical unprefixed route', async () => { + const { proxy } = await import('./proxy') + const response = await proxy(createRequest('https://www.tradinggoose.ai/es/privacy')) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe('https://www.tradinggoose.ai/privacy') + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + }) + + it('redirects default-locale prefixed routes to the canonical unprefixed route', async () => { + const { proxy } = await import('./proxy') + const response = await proxy(createRequest('https://www.tradinggoose.ai/en/blog')) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe('https://www.tradinggoose.ai/blog') + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + }) + + it('preserves the locale when redirecting invitation accept requests', async () => { + const { proxy } = await import('./proxy') + const response = await proxy( + createRequest('https://www.tradinggoose.ai/es/api/workspaces/invitations/accept?token=abc123') + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://www.tradinggoose.ai/es/invite/abc123?token=abc123' + ) + }) + + it('preserves the locale when redirecting invitation accept requests for zh', async () => { + const { proxy } = await import('./proxy') + const response = await proxy( + createRequest( + 'https://www.tradinggoose.ai/zh/api/workspaces/invitations/accept?token=abc123' + ) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://www.tradinggoose.ai/zh/invite/abc123?token=abc123' + ) + }) + + it('rejects the old zh-CN public prefix', async () => { + const { proxy } = await import('./proxy') + const response = await proxy(createRequest('https://www.tradinggoose.ai/zh-CN/blog')) + + expect(response.status).toBe(404) expect(response.headers.get('x-middleware-rewrite')).toBeNull() + expect(response.headers.get('location')).toBeNull() + }) + + it('preserves the locale when redirecting a protected route to login', async () => { + const { proxy } = await import('./proxy') + const response = await proxy( + createRequest('https://www.tradinggoose.ai/es/workspace/ws-1/dashboard') + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://www.tradinggoose.ai/es/login?callbackUrl=%2Fes%2Fworkspace%2Fws-1%2Fdashboard' + ) + }) + + it('rewrites locale-prefixed protected routes to the unprefixed route when session exists', async () => { + mockGetSessionCookie.mockReturnValue('active-session') + + const { proxy } = await import('./proxy') + const response = await proxy( + createRequest('https://www.tradinggoose.ai/es/workspace/ws-1/dashboard?layoutId=layout-1') + ) + + expect(response.status).toBe(200) + expect(response.headers.get('x-middleware-rewrite')).toBe( + 'https://www.tradinggoose.ai/workspace/ws-1/dashboard?layoutId=layout-1' + ) + expect(response.headers.get('location')).toBeNull() }) it('allows the login route through when reauth is explicitly requested', async () => { @@ -57,11 +179,102 @@ describe('proxy auth routing', () => { const { proxy } = await import('./proxy') const response = await proxy( - new NextRequest('http://localhost:3000/login?reauth=1&callbackUrl=%2Fworkspace%2Fws-1') + createRequest('http://localhost:3000/login?reauth=1&callbackUrl=%2Fworkspace%2Fws-1') ) expect(response.status).toBe(200) expect(response.headers.get('location')).toBeNull() expect(response.cookies.get('better-auth.session_token')?.maxAge).toBe(0) }) + + it('allows locale-prefixed login routes through when reauth is explicitly requested', async () => { + mockGetSessionCookie.mockReturnValue('stale-cookie') + + const { proxy } = await import('./proxy') + const response = await proxy( + createRequest( + 'https://www.tradinggoose.ai/es/login?reauth=1&callbackUrl=%2Fes%2Fworkspace%2Fws-1' + ) + ) + + expect(response.status).toBe(200) + expect(response.headers.get('location')).toBeNull() + expect(response.headers.get('x-middleware-rewrite')).toBe( + 'https://www.tradinggoose.ai/login?reauth=1&callbackUrl=%2Fes%2Fworkspace%2Fws-1' + ) + expect(response.cookies.get('better-auth.session_token')?.maxAge).toBe(0) + }) +}) + +describe('proxy matcher extraction', () => { + const proxyFilePath = fileURLToPath(new URL('./proxy.ts', import.meta.url)) + const pageType = 'pages' as any + const expectedMatchers = [ + '/', + '/terms', + '/privacy', + '/workspace/:path*', + '/login', + '/signup', + '/invite/:path*', + '/((?!_next/static|_next/image|blog-images/|favicon.ico|logo/|static/|footer/|social/|enterprise/|favicon/|twitter/|robots.txt|sitemap.xml).*)', + ] + + it('recovers proxy matchers after SWC bindings are installed', async () => { + const { getPageStaticInfo } = await import('next/dist/build/analysis/get-page-static-info.js') + const { installBindings } = await import('next/dist/build/swc/install-bindings.js') + + await expect( + getPageStaticInfo({ + pageType, + nextConfig: {}, + pageFilePath: proxyFilePath, + isDev: true, + page: '/proxy', + }) + ).rejects.toMatchObject({ __NEXT_ERROR_CODE: 'E907' }) + + await installBindings() + + const staticInfo = await getPageStaticInfo({ + pageType, + nextConfig: {}, + pageFilePath: proxyFilePath, + isDev: true, + page: '/proxy', + }) + + expect(staticInfo.middleware?.matchers?.map(({ originalSource }) => originalSource)).toEqual( + expectedMatchers + ) + }) + + it('fails fast when a proxy file exports an empty matcher list', async () => { + const { getPageStaticInfo } = await import('next/dist/build/analysis/get-page-static-info.js') + const { installBindings } = await import('next/dist/build/swc/install-bindings.js') + + await installBindings() + + const tempDir = mkdtempSync(join(tmpdir(), 'tradinggoose-proxy-')) + const tempFile = join(tempDir, 'proxy.ts') + + try { + writeFileSync( + tempFile, + `export function proxy() {\n return null\n}\n\nexport const config = {\n matcher: [],\n}\n` + ) + + await expect( + getPageStaticInfo({ + pageType, + nextConfig: {}, + pageFilePath: tempFile, + isDev: true, + page: '/proxy', + }) + ).rejects.toMatchObject({ __NEXT_ERROR_CODE: 'E1143' }) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) }) diff --git a/apps/tradinggoose/proxy.ts b/apps/tradinggoose/proxy.ts index ffc11b273..f9666ed81 100644 --- a/apps/tradinggoose/proxy.ts +++ b/apps/tradinggoose/proxy.ts @@ -8,12 +8,26 @@ import { MARKDOWN_RENDER_ROUTE, requestAcceptsMarkdown, } from '@/lib/markdown/negotiation' +import { + defaultLocale, + type LocaleCode, + localizePathname, + stripLocaleFromPathname, +} from '@/i18n/utils' import { createLogger } from './lib/logs/console/logger' import { generateRuntimeCSP } from './lib/security/csp' const logger = createLogger('Proxy') +const NEXT_INTL_LOCALE_HEADER = 'X-NEXT-INTL-LOCALE' const AUTH_ROUTES = new Set(['/login', '/signup']) +const ENGLISH_ONLY_PUBLIC_ROUTES = new Set([ + '/privacy', + '/terms', + '/licenses', + '/careers', + '/changelog', +]) const AUTH_COOKIE_KEYS = [ 'better-auth.session_token', 'better-auth.session_data', @@ -42,8 +56,58 @@ const SUSPICIOUS_UA_PATTERNS = [ /\b(sqlmap|nikto|gobuster|dirb|nmap)\b/i, // Known scanning tools ] as const +function createRequestHeaders( + request: NextRequest, + locale: LocaleCode, + extraHeaders: Record<string, string> = {} +) { + const headers = new Headers(request.headers) + headers.set(NEXT_INTL_LOCALE_HEADER, locale) + + Object.entries(extraHeaders).forEach(([key, value]) => { + headers.set(key, value) + }) + + return headers +} + +function buildNormalizedUrl(request: NextRequest, pathname: string) { + const normalizedUrl = new URL(pathname, request.url) + normalizedUrl.search = request.nextUrl.search + return normalizedUrl +} + +function buildLocaleAwareResponse( + request: NextRequest, + locale: LocaleCode, + pathname: string, + extraRequestHeaders: Record<string, string> = {} +) { + const hasLocalePrefix = pathname !== request.nextUrl.pathname + const normalizedUrl = buildNormalizedUrl(request, pathname) + + if (hasLocalePrefix && locale === defaultLocale) { + return NextResponse.redirect(normalizedUrl) + } + + if (hasLocalePrefix) { + return NextResponse.rewrite(normalizedUrl, { + request: { + headers: createRequestHeaders(request, locale, extraRequestHeaders), + }, + }) + } + + return NextResponse.next({ + request: { + headers: createRequestHeaders(request, locale, extraRequestHeaders), + }, + }) +} + function buildLoginRedirect(request: NextRequest, callback?: string) { - const loginUrl = new URL('/login', request.url) + const { locale } = stripLocaleFromPathname(request.nextUrl.pathname) + const loginUrl = new URL(localizePathname(locale, '/login'), request.url) if (callback) { loginUrl.searchParams.set('callbackUrl', callback) } @@ -51,14 +115,38 @@ function buildLoginRedirect(request: NextRequest, callback?: string) { } function isProtectedAppPath(pathname: string): boolean { + const { pathname: normalizedPathname } = stripLocaleFromPathname(pathname) + return ( - pathname.startsWith('/workspace') || - pathname === '/admin' || - pathname.startsWith('/admin/') || - pathname === '/workspace/' + normalizedPathname.startsWith('/workspace') || + normalizedPathname === '/admin' || + normalizedPathname.startsWith('/admin/') || + normalizedPathname === '/workspace/' ) } +function isAuthRoute(pathname: string): boolean { + const { pathname: normalizedPathname } = stripLocaleFromPathname(pathname) + + return AUTH_ROUTES.has(normalizedPathname) +} + +function getLocalizedCallbackPath(pathname: string, search: string) { + const { locale, pathname: normalizedPathname } = stripLocaleFromPathname(pathname) + return `${localizePathname(locale, normalizedPathname)}${search}` +} + +function isMarkdownRequestPath(pathname: string) { + const { pathname: normalizedPathname } = stripLocaleFromPathname(pathname) + + return isMarkdownRenderablePath(normalizedPathname) +} + +function getLocalizedWorkspacePath(pathname: string) { + const { locale } = stripLocaleFromPathname(pathname) + return localizePathname(locale, '/workspace') +} + function rewriteMarkdownRequest(request: NextRequest): NextResponse | null { if (request.method !== 'GET' && request.method !== 'HEAD') { return null @@ -76,15 +164,22 @@ function rewriteMarkdownRequest(request: NextRequest): NextResponse | null { return null } - if (!isMarkdownRenderablePath(request.nextUrl.pathname)) { + if (!isMarkdownRequestPath(request.nextUrl.pathname)) { return null } + const { locale, pathname: normalizedPathname } = stripLocaleFromPathname(request.nextUrl.pathname) + + if (locale === defaultLocale && normalizedPathname !== request.nextUrl.pathname) { + return NextResponse.redirect(buildNormalizedUrl(request, normalizedPathname)) + } + const rewriteUrl = new URL(MARKDOWN_RENDER_ROUTE, request.url) rewriteUrl.searchParams.set('path', request.nextUrl.pathname) - const requestHeaders = new Headers(request.headers) - requestHeaders.set(MARKDOWN_BYPASS_HEADER, '1') + const requestHeaders = createRequestHeaders(request, locale, { + [MARKDOWN_BYPASS_HEADER]: '1', + }) return NextResponse.rewrite(rewriteUrl, { request: { @@ -100,17 +195,22 @@ function handleWorkspaceInvitationAPI( request: NextRequest, hasActiveSession: boolean ): NextResponse | null { - if (!request.nextUrl.pathname.startsWith('/api/workspaces/invitations')) { + const { locale, pathname: normalizedPathname } = stripLocaleFromPathname(request.nextUrl.pathname) + + if (!normalizedPathname.startsWith('/api/workspaces/invitations')) { return null } - if (request.nextUrl.pathname.includes('/accept') && !hasActiveSession) { + if (normalizedPathname.includes('/accept') && !hasActiveSession) { const token = request.nextUrl.searchParams.get('token') if (token) { - return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, request.url)) + const inviteUrl = new URL(localizePathname(locale, `/invite/${token}`), request.url) + inviteUrl.searchParams.set('token', token) + return NextResponse.redirect(inviteUrl) } } - return NextResponse.next() + + return buildLocaleAwareResponse(request, locale, normalizedPathname) } /** @@ -152,27 +252,32 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null { export async function proxy(request: NextRequest) { const url = request.nextUrl + if (url.pathname === '/zh-CN' || url.pathname.startsWith('/zh-CN/')) { + return new NextResponse(null, { status: 404 }) + } + + const { locale, pathname: normalizedPathname } = stripLocaleFromPathname(url.pathname) + const hasActiveSession = Boolean(getSessionCookie(request)) const isProtectedPath = isProtectedAppPath(url.pathname) + const reauth = url.searchParams.get('reauth') === '1' if (isProtectedPath) { if (!hasActiveSession) { - const callbackTarget = `${url.pathname}${url.search}` + const callbackTarget = getLocalizedCallbackPath(url.pathname, url.search) return buildLoginRedirect(request, callbackTarget) } } - const reauth = url.searchParams.get('reauth') === '1' - - if (AUTH_ROUTES.has(url.pathname)) { + if (isAuthRoute(url.pathname)) { if (reauth) { - const response = NextResponse.next() + const response = buildLocaleAwareResponse(request, locale, normalizedPathname) clearAuthCookies(response) return response } if (hasActiveSession) { - return NextResponse.redirect(new URL('/workspace', request.url)) + return NextResponse.redirect(new URL(getLocalizedWorkspacePath(url.pathname), request.url)) } } @@ -182,31 +287,35 @@ export async function proxy(request: NextRequest) { const securityBlock = handleSecurityFiltering(request) if (securityBlock) return securityBlock + if (ENGLISH_ONLY_PUBLIC_ROUTES.has(normalizedPathname) && normalizedPathname !== url.pathname) { + return NextResponse.redirect(buildNormalizedUrl(request, normalizedPathname)) + } + const markdownRewrite = rewriteMarkdownRequest(request) if (markdownRewrite) return markdownRewrite - const requestHeaders = new Headers(request.headers) - if (isProtectedPath) { - requestHeaders.set('x-auth-callback-url', `${url.pathname}${url.search}`) + const requestHeaders: Record<string, string> = isProtectedPath + ? { 'x-auth-callback-url': getLocalizedCallbackPath(url.pathname, url.search) } + : {} + + const response = buildLocaleAwareResponse(request, locale, normalizedPathname, requestHeaders) + + if (response.headers.has('location')) { + return response } - const response = NextResponse.next({ - request: { - headers: requestHeaders, - }, - }) response.headers.set('Vary', appendVaryHeader(appendVaryHeader(null, 'User-Agent'), 'Accept')) if ( - url.pathname.startsWith('/workspace') || - url.pathname.startsWith('/chat') || - url.pathname === '/' + normalizedPathname.startsWith('/workspace') || + normalizedPathname.startsWith('/chat') || + normalizedPathname === '/' ) { response.headers.set('Content-Security-Policy', await generateRuntimeCSP()) } - if (url.pathname === '/') { - appendHomepageDiscoveryLinks(response.headers) + if (normalizedPathname === '/') { + appendHomepageDiscoveryLinks(response.headers, locale) } return response diff --git a/apps/tradinggoose/socket-server/handlers/index.ts b/apps/tradinggoose/socket-server/handlers/index.ts index 1e13cc00d..8cc8d0e1f 100644 --- a/apps/tradinggoose/socket-server/handlers/index.ts +++ b/apps/tradinggoose/socket-server/handlers/index.ts @@ -1,5 +1,6 @@ import { setupConnectionHandlers } from '@/socket-server/handlers/connection' import { setupMarketHandlers } from '@/socket-server/handlers/market' +import { setupTradingPortfolioHandlers } from '@/socket-server/handlers/trading' import type { AuthenticatedSocket } from '@/socket-server/middleware/auth' /** @@ -9,9 +10,11 @@ import type { AuthenticatedSocket } from '@/socket-server/middleware/auth' export function setupAllHandlers(socket: AuthenticatedSocket) { setupConnectionHandlers(socket) setupMarketHandlers(socket) + setupTradingPortfolioHandlers(socket) } export { setupConnectionHandlers, setupMarketHandlers, + setupTradingPortfolioHandlers, } diff --git a/apps/tradinggoose/socket-server/handlers/market.ts b/apps/tradinggoose/socket-server/handlers/market.ts index 09306f72e..ff8ff5459 100644 --- a/apps/tradinggoose/socket-server/handlers/market.ts +++ b/apps/tradinggoose/socket-server/handlers/market.ts @@ -20,7 +20,13 @@ export function setupMarketHandlers(socket: AuthenticatedSocket) { userId: socket.userId, error: message, }) - socket.emit('market-subscribe-error', { error: message }) + socket.emit('market-subscribe-error', { + error: message, + provider: payload?.provider, + channel: payload?.channel, + clientSubscriptionId: payload?.clientSubscriptionId, + listing: payload?.listing, + }) } }) @@ -35,7 +41,12 @@ export function setupMarketHandlers(socket: AuthenticatedSocket) { userId: socket.userId, error: message, }) - socket.emit('market-unsubscribe-error', { error: message }) + socket.emit('market-unsubscribe-error', { + error: message, + provider: payload?.provider, + clientSubscriptionId: payload?.clientSubscriptionId, + listing: payload?.listing, + }) } }) diff --git a/apps/tradinggoose/socket-server/handlers/trading.ts b/apps/tradinggoose/socket-server/handlers/trading.ts new file mode 100644 index 000000000..05235b475 --- /dev/null +++ b/apps/tradinggoose/socket-server/handlers/trading.ts @@ -0,0 +1,82 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { AuthenticatedSocket } from '@/socket-server/middleware/auth' +import { + type TradingPortfolioSubscribePayload, + type TradingPortfolioUnsubscribePayload, + tradingPortfolioStreamManager, +} from '@/socket-server/trading/portfolio-manager' + +const logger = createLogger('TradingPortfolioHandlers') + +export function setupTradingPortfolioHandlers(socket: AuthenticatedSocket) { + socket.on('trading-portfolio-subscribe', async (payload: TradingPortfolioSubscribePayload) => { + try { + const subscription = await tradingPortfolioStreamManager.subscribe(socket, payload) + socket.emit('trading-portfolio-subscribed', subscription) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn('Trading portfolio subscribe failed', { + socketId: socket.id, + userId: socket.userId, + error: message, + }) + socket.emit('trading-portfolio-subscribe-error', { + error: message, + provider: payload?.provider, + credentialServiceId: payload?.credentialServiceId, + channel: payload?.channel, + accountId: payload?.accountId, + window: payload?.window, + clientSubscriptionId: payload?.clientSubscriptionId, + }) + } + }) + + socket.on('trading-portfolio-unsubscribe', (payload: TradingPortfolioUnsubscribePayload) => { + try { + const removed = tradingPortfolioStreamManager.unsubscribe(socket, payload) + socket.emit('trading-portfolio-unsubscribed', { subscriptions: removed }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn('Trading portfolio unsubscribe failed', { + socketId: socket.id, + userId: socket.userId, + error: message, + }) + socket.emit('trading-portfolio-unsubscribe-error', { + error: message, + provider: payload?.provider, + credentialServiceId: payload?.credentialServiceId, + channel: payload?.channel, + accountId: payload?.accountId, + clientSubscriptionId: payload?.clientSubscriptionId, + }) + } + }) + + socket.on('trading-portfolio-refresh', (payload: TradingPortfolioUnsubscribePayload) => { + try { + const refreshed = tradingPortfolioStreamManager.refresh(socket, payload) + socket.emit('trading-portfolio-refreshing', { subscriptions: refreshed }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn('Trading portfolio refresh failed', { + socketId: socket.id, + userId: socket.userId, + error: message, + }) + socket.emit('trading-portfolio-error', { + error: message, + provider: payload?.provider, + credentialServiceId: payload?.credentialServiceId, + channel: payload?.channel, + accountId: payload?.accountId, + clientSubscriptionId: payload?.clientSubscriptionId, + }) + } + }) + + socket.on('disconnect', () => { + tradingPortfolioStreamManager.removeSocket(socket.id) + }) +} diff --git a/apps/tradinggoose/socket-server/market/manager.test.ts b/apps/tradinggoose/socket-server/market/manager.test.ts index 8788e9dee..d2dae5e5f 100644 --- a/apps/tradinggoose/socket-server/market/manager.test.ts +++ b/apps/tradinggoose/socket-server/market/manager.test.ts @@ -7,6 +7,24 @@ const { getEffectiveDecryptedEnvMock } = vi.hoisted(() => ({ getEffectiveDecryptedEnvMock: vi.fn(), })) +const { + buildMarketQuoteSnapshotMock, + getMarketLiveCapabilitiesMock, + getMarketProviderConfigMock, + resolveListingContextMock, + resolveProviderSymbolMock, + alpacaStreamInstances, + finnhubStreamInstances, +} = vi.hoisted(() => ({ + buildMarketQuoteSnapshotMock: vi.fn(), + getMarketLiveCapabilitiesMock: vi.fn(), + getMarketProviderConfigMock: vi.fn(), + resolveListingContextMock: vi.fn(), + resolveProviderSymbolMock: vi.fn(), + alpacaStreamInstances: [] as any[], + finnhubStreamInstances: [] as any[], +})) + vi.mock('@/lib/environment/utils', () => ({ getEffectiveDecryptedEnv: getEffectiveDecryptedEnvMock, })) @@ -15,6 +33,10 @@ vi.mock('@/lib/listing/identity', () => ({ areListingIdentitiesEqual: vi.fn(() => false), })) +vi.mock('@/lib/market/quote-snapshots', () => ({ + buildMarketQuoteSnapshot: buildMarketQuoteSnapshotMock, +})) + vi.mock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => ({ info: vi.fn(), @@ -31,20 +53,78 @@ vi.mock('@/providers/market/finnhub/config', () => ({ finnhubProviderConfig: {}, })) +vi.mock('@/providers/market/providers', () => ({ + getMarketLiveCapabilities: getMarketLiveCapabilitiesMock, + getMarketProviderConfig: getMarketProviderConfigMock, +})) + vi.mock('@/providers/market/utils', () => ({ - resolveListingContext: vi.fn(), - resolveProviderSymbol: vi.fn(), + resolveListingContext: resolveListingContextMock, + resolveProviderSymbol: resolveProviderSymbolMock, })) vi.mock('@/socket-server/market/alpaca', () => ({ - AlpacaMarketStream: class {}, + AlpacaMarketStream: class { + subscribe = vi.fn() + unsubscribe = vi.fn() + close = vi.fn() + + constructor(config: unknown, handlers: unknown) { + alpacaStreamInstances.push({ + config, + handlers, + subscribe: this.subscribe, + unsubscribe: this.unsubscribe, + close: this.close, + }) + } + }, })) vi.mock('@/socket-server/market/finnhub', () => ({ - FinnhubMarketStream: class {}, + FinnhubMarketStream: class { + subscribe = vi.fn() + unsubscribe = vi.fn() + close = vi.fn() + + constructor(config: unknown, handlers: unknown) { + finnhubStreamInstances.push({ + config, + handlers, + subscribe: this.subscribe, + unsubscribe: this.unsubscribe, + close: this.close, + }) + } + }, })) -import { resolveMarketSubscribeEnv, type MarketSubscribePayload } from './manager' +import { + MarketStreamManager, + resolveMarketSubscribeEnv, + type MarketSubscribePayload, +} from './manager' + +const listing = { + listing_id: 'us-aapl', + base_id: '', + quote_id: '', + listing_type: 'default' as const, +} + +const quoteSnapshot = { + lastPrice: 123.45, + previousClose: 120, + change: 3.45, + changePercent: 2.875, +} + +const createSocket = (id: string) => + ({ + id, + userId: 'user-1', + emit: vi.fn(), + }) as any describe('resolveMarketSubscribeEnv', () => { const originalEnv = process.env.RUNTIME_ONLY_KEY @@ -114,3 +194,176 @@ describe('resolveMarketSubscribeEnv', () => { expect(getEffectiveDecryptedEnvMock).toHaveBeenCalledWith('user-1', 'workspace-1') }) }) + +describe('MarketStreamManager quote snapshots', () => { + beforeEach(() => { + vi.clearAllMocks() + alpacaStreamInstances.length = 0 + finnhubStreamInstances.length = 0 + buildMarketQuoteSnapshotMock.mockResolvedValue(quoteSnapshot) + getMarketLiveCapabilitiesMock.mockImplementation((provider: string) => + provider === 'yahoo-finance' + ? { + supportsPolling: true, + channels: ['quote-snapshots'], + pollingIntervalMs: 5_000, + } + : null + ) + getMarketProviderConfigMock.mockReturnValue({}) + resolveListingContextMock.mockResolvedValue({ + listing, + base: 'AAPL', + assetClass: 'stock', + }) + resolveProviderSymbolMock.mockReturnValue('AAPL') + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('rejects subscriptions without an explicit market provider', async () => { + const manager = new MarketStreamManager() + const socket = createSocket('socket-1') + + await expect( + manager.subscribe(socket, { + workspaceId: 'workspace-1', + listing, + channel: 'quote-snapshots', + clientSubscriptionId: 'quote-1', + }) + ).rejects.toThrow('market provider is required') + + expect(alpacaStreamInstances).toHaveLength(0) + expect(finnhubStreamInstances).toHaveLength(0) + }) + + it('shares one upstream trade subscription for duplicate streaming quote snapshots', async () => { + const manager = new MarketStreamManager() + const socket = createSocket('socket-1') + + const first = await manager.subscribe(socket, { + provider: 'alpaca', + workspaceId: 'workspace-1', + listing, + channel: 'quote-snapshots', + clientSubscriptionId: 'quote-1', + auth: { + apiKey: 'alpaca-key', + apiSecret: 'alpaca-secret', + }, + }) + const second = await manager.subscribe(socket, { + provider: 'alpaca', + workspaceId: 'workspace-1', + listing, + channel: 'quote-snapshots', + clientSubscriptionId: 'quote-2', + auth: { + apiKey: 'alpaca-key', + apiSecret: 'alpaca-secret', + }, + }) + + expect(first.subscriptionId).not.toBe(second.subscriptionId) + expect(alpacaStreamInstances).toHaveLength(1) + expect(alpacaStreamInstances[0].subscribe).toHaveBeenCalledTimes(1) + expect(alpacaStreamInstances[0].subscribe).toHaveBeenCalledWith(['AAPL'], 'trades') + expect(buildMarketQuoteSnapshotMock).not.toHaveBeenCalled() + + manager.removeSocket(socket.id) + }) + + it('keeps streaming quote streams separated by workspace', async () => { + const manager = new MarketStreamManager() + const firstSocket = createSocket('socket-1') + const secondSocket = createSocket('socket-2') + + await manager.subscribe(firstSocket, { + provider: 'alpaca', + workspaceId: 'workspace-1', + listing, + channel: 'quote-snapshots', + clientSubscriptionId: 'quote-1', + auth: { + apiKey: 'alpaca-key', + apiSecret: 'alpaca-secret', + }, + }) + await manager.subscribe(secondSocket, { + provider: 'alpaca', + workspaceId: 'workspace-2', + listing, + channel: 'quote-snapshots', + clientSubscriptionId: 'quote-2', + auth: { + apiKey: 'alpaca-key', + apiSecret: 'alpaca-secret', + }, + }) + + expect(alpacaStreamInstances).toHaveLength(2) + expect(alpacaStreamInstances[0].subscribe).toHaveBeenCalledWith(['AAPL'], 'trades') + expect(alpacaStreamInstances[1].subscribe).toHaveBeenCalledWith(['AAPL'], 'trades') + + manager.removeSocket(firstSocket.id) + manager.removeSocket(secondSocket.id) + }) + + it('uses one polling pull for duplicate polling-provider quote snapshots', async () => { + vi.useFakeTimers() + const manager = new MarketStreamManager() + const firstSocket = createSocket('socket-1') + const secondSocket = createSocket('socket-2') + + await manager.subscribe(firstSocket, { + provider: 'yahoo-finance', + workspaceId: 'workspace-1', + listing, + channel: 'quote-snapshots', + clientSubscriptionId: 'quote-1', + }) + await manager.subscribe(secondSocket, { + provider: 'yahoo-finance', + workspaceId: 'workspace-1', + listing, + channel: 'quote-snapshots', + clientSubscriptionId: 'quote-2', + }) + + await Promise.resolve() + await Promise.resolve() + + expect(buildMarketQuoteSnapshotMock).toHaveBeenCalledTimes(1) + expect(firstSocket.emit).toHaveBeenCalledWith( + 'market-quote-snapshot', + expect.objectContaining({ + provider: 'yahoo-finance', + channel: 'quote-snapshots', + clientSubscriptionId: 'quote-1', + snapshot: quoteSnapshot, + }) + ) + expect(secondSocket.emit).toHaveBeenCalledWith( + 'market-quote-snapshot', + expect.objectContaining({ + provider: 'yahoo-finance', + channel: 'quote-snapshots', + clientSubscriptionId: 'quote-2', + snapshot: quoteSnapshot, + }) + ) + + buildMarketQuoteSnapshotMock.mockClear() + vi.advanceTimersByTime(5_000) + await Promise.resolve() + await Promise.resolve() + + expect(buildMarketQuoteSnapshotMock).toHaveBeenCalledTimes(1) + + manager.removeSocket(firstSocket.id) + manager.removeSocket(secondSocket.id) + }) +}) diff --git a/apps/tradinggoose/socket-server/market/manager.ts b/apps/tradinggoose/socket-server/market/manager.ts index e4c929d9a..7b2744467 100644 --- a/apps/tradinggoose/socket-server/market/manager.ts +++ b/apps/tradinggoose/socket-server/market/manager.ts @@ -1,10 +1,20 @@ -import { createHash } from 'crypto' +import { createHash, randomUUID } from 'crypto' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' +import { stableStringifyJsonValue } from '@/lib/json/stable' import { areListingIdentitiesEqual, type ListingIdentity } from '@/lib/listing/identity' import { createLogger } from '@/lib/logs/console/logger' +import { + createEmptyMarketQuoteSnapshot, + type MarketQuoteSnapshot, +} from '@/lib/market/quote-snapshot-contract' +import { buildMarketQuoteSnapshot } from '@/lib/market/quote-snapshots' import { alpacaProviderConfig } from '@/providers/market/alpaca/config' import { finnhubProviderConfig } from '@/providers/market/finnhub/config' -import type { MarketBar } from '@/providers/market/types' +import { + getMarketLiveCapabilities, + getMarketProviderConfig, +} from '@/providers/market/providers' +import type { MarketBar, MarketProviderAuth, MarketProviderParams } from '@/providers/market/types' import { resolveListingContext, resolveProviderSymbol } from '@/providers/market/utils' import type { AuthenticatedSocket } from '@/socket-server/middleware/auth' import { @@ -16,12 +26,19 @@ import { import { FinnhubMarketStream } from './finnhub' const logger = createLogger('MarketStreamManager') +const DEFAULT_POLLING_INTERVAL_MS = 15_000 +const MIN_POLLING_INTERVAL_MS = 5_000 +const POLLING_CONCURRENCY = 5 export type MarketProviderId = 'alpaca' | 'finnhub' -export type MarketChannel = 'bars' | 'trades' | 'quotes' +export type PollingMarketProviderId = 'alpha-vantage' | 'yahoo-finance' +export type AnyMarketProviderId = MarketProviderId | PollingMarketProviderId +export type MarketStreamChannel = 'bars' | 'trades' | 'quotes' +export type MarketChannel = MarketStreamChannel | 'quote-snapshots' export interface MarketSubscribePayload { - provider?: MarketProviderId + provider?: AnyMarketProviderId + clientSubscriptionId?: string workspaceId?: string listing?: ListingIdentity channel?: MarketChannel @@ -38,16 +55,18 @@ export interface MarketSubscribePayload { export interface MarketUnsubscribePayload { subscriptionId?: string + clientSubscriptionId?: string listing?: ListingIdentity symbol?: string - provider?: MarketProviderId + provider?: AnyMarketProviderId } export interface MarketSubscriptionInfo { subscriptionId: string + clientSubscriptionId?: string listing: ListingIdentity | null symbol: string - provider: MarketProviderId + provider: AnyMarketProviderId market: AlpacaMarket channel: MarketChannel interval?: string @@ -57,23 +76,29 @@ interface MarketSubscriptionRecord extends MarketSubscriptionInfo { streamKey: string socketId: string socket: AuthenticatedSocket + upstreamChannel?: MarketStreamChannel listingBase?: string listingQuote?: string } type MarketStream = { - subscribe: (symbols: string[], channel?: MarketChannel) => void - unsubscribe: (symbols: string[], channel?: MarketChannel) => void + subscribe: (symbols: string[], channel?: MarketStreamChannel) => void + unsubscribe: (symbols: string[], channel?: MarketStreamChannel) => void close: () => void } interface StreamState { - stream: MarketStream - provider: MarketProviderId + stream?: MarketStream + provider: AnyMarketProviderId market: AlpacaMarket feed?: AlpacaFeed cryptoRegion?: AlpacaCryptoRegion - apiKey?: string + auth?: MarketProviderAuth + providerParams?: MarketProviderParams + pollingTimer?: ReturnType<typeof setInterval> + pollingInFlight?: boolean + pollingIntervalMs?: number + quoteSnapshotCache: Map<string, MarketQuoteSnapshot> subscribersBySymbol: Map<string, Map<string, MarketSubscriptionRecord>> } @@ -89,14 +114,14 @@ export class MarketStreamManager { const provider = resolveProviderId(resolvedPayload.provider) if (provider === 'alpaca') { - return this.subscribeAlpaca(socket, resolvedPayload) + return this.subscribeAlpaca(socket, { ...resolvedPayload, provider }) } if (provider === 'finnhub') { - return this.subscribeFinnhub(socket, resolvedPayload) + return this.subscribeFinnhub(socket, { ...resolvedPayload, provider }) } - throw new Error('Unsupported market data provider') + return this.subscribePollingProvider(socket, { ...resolvedPayload, provider }) } unsubscribe( @@ -117,6 +142,7 @@ export class MarketStreamManager { return matches.map((record) => ({ subscriptionId: record.subscriptionId, + clientSubscriptionId: record.clientSubscriptionId, listing: record.listing, symbol: record.symbol, provider: record.provider, @@ -143,9 +169,15 @@ export class MarketStreamManager { } const channel = payload.channel ?? 'bars' - if (channel !== 'bars' && channel !== 'trades' && channel !== 'quotes') { + if ( + channel !== 'bars' && + channel !== 'trades' && + channel !== 'quotes' && + channel !== 'quote-snapshots' + ) { throw new Error('Unsupported Alpaca channel') } + const upstreamChannel = resolveUpstreamChannel(channel) const context = await resolveListingContext(listing) const market = resolveMarket(payload, context.assetClass) @@ -169,6 +201,7 @@ export class MarketStreamManager { const streamKey = buildAlpacaStreamKey({ provider: 'alpaca', + workspaceId: payload.workspaceId, market, feed, cryptoRegion, @@ -183,15 +216,27 @@ export class MarketStreamManager { cryptoRegion, keyId, secretKey, + auth: { + apiKey: keyId, + apiSecret: secretKey, + }, + providerParams: payload.providerParams, }) const intervalToken = typeof payload.interval === 'string' && payload.interval.trim() ? payload.interval.trim() : 'na' - const subscriptionId = `${streamKey}:${channel}:${symbol}:${intervalToken}` + const subscriptionId = createSubscriptionId({ + streamKey, + channel, + symbol, + interval: intervalToken, + clientSubscriptionId: payload.clientSubscriptionId, + }) const record: MarketSubscriptionRecord = { subscriptionId, + clientSubscriptionId: payload.clientSubscriptionId, streamKey, listing, socketId: socket.id, @@ -200,6 +245,7 @@ export class MarketStreamManager { provider: 'alpaca', market, channel, + upstreamChannel, interval: payload.interval, listingBase: context.base, listingQuote: context.quote, @@ -219,6 +265,7 @@ export class MarketStreamManager { return { subscriptionId, + clientSubscriptionId: payload.clientSubscriptionId, listing, symbol, provider: 'alpaca', @@ -238,9 +285,10 @@ export class MarketStreamManager { } const channel = payload.channel ?? 'trades' - if (channel !== 'bars' && channel !== 'trades') { + if (channel !== 'bars' && channel !== 'trades' && channel !== 'quote-snapshots') { throw new Error('Finnhub streaming supports bars and trades only') } + const upstreamChannel = resolveUpstreamChannel(channel) const context = await resolveListingContext(listing) const market = resolveMarket(payload, context.assetClass) @@ -259,20 +307,35 @@ export class MarketStreamManager { throw new Error('Finnhub API key is required for streaming') } - const streamKey = buildFinnhubStreamKey({ provider: 'finnhub', apiKey }) + const streamKey = buildFinnhubStreamKey({ + provider: 'finnhub', + workspaceId: payload.workspaceId, + apiKey, + }) const streamState = this.getOrCreateStream(streamKey, { provider: 'finnhub', market, apiKey, + auth: { + apiKey, + }, + providerParams: payload.providerParams, }) const intervalToken = typeof payload.interval === 'string' && payload.interval.trim() ? payload.interval.trim() : 'na' - const subscriptionId = `${streamKey}:${channel}:${symbol}:${intervalToken}` + const subscriptionId = createSubscriptionId({ + streamKey, + channel, + symbol, + interval: intervalToken, + clientSubscriptionId: payload.clientSubscriptionId, + }) const record: MarketSubscriptionRecord = { subscriptionId, + clientSubscriptionId: payload.clientSubscriptionId, streamKey, listing, socketId: socket.id, @@ -281,6 +344,7 @@ export class MarketStreamManager { provider: 'finnhub', market, channel, + upstreamChannel, interval: payload.interval, listingBase: context.base, listingQuote: context.quote, @@ -300,6 +364,7 @@ export class MarketStreamManager { return { subscriptionId, + clientSubscriptionId: payload.clientSubscriptionId, listing, symbol, provider: 'finnhub', @@ -309,27 +374,135 @@ export class MarketStreamManager { } } + private async subscribePollingProvider( + socket: AuthenticatedSocket, + payload: MarketSubscribePayload & { provider: PollingMarketProviderId } + ): Promise<MarketSubscriptionInfo> { + const listing = payload.listing + if (!listing) { + throw new Error('listing is required to subscribe to market data') + } + + const channel = payload.channel ?? 'quote-snapshots' + if (channel !== 'quote-snapshots') { + throw new Error('Polling market providers support quote snapshots only') + } + + const capabilities = getMarketLiveCapabilities(payload.provider) + if (!capabilities?.supportsPolling) { + throw new Error(`Provider ${payload.provider} does not support polling market streams`) + } + + const providerConfig = getMarketProviderConfig(payload.provider) + if (!providerConfig) { + throw new Error(`Market provider not found: ${payload.provider}`) + } + + const context = await resolveListingContext(listing) + const market = resolveMarket(payload, context.assetClass) + const symbol = normalizeSymbol(resolveProviderSymbol(providerConfig, context)) + if (!symbol) { + throw new Error('Failed to resolve provider symbol for listing') + } + + const streamKey = buildPollingStreamKey({ + provider: payload.provider, + workspaceId: payload.workspaceId, + auth: payload.auth, + providerParams: payload.providerParams, + }) + const streamState = this.getOrCreatePollingStream(streamKey, { + provider: payload.provider, + auth: payload.auth, + providerParams: payload.providerParams, + pollingIntervalMs: resolvePollingIntervalMs(payload.provider, payload.providerParams), + }) + + const intervalToken = + typeof payload.interval === 'string' && payload.interval.trim() + ? payload.interval.trim() + : 'na' + const subscriptionId = createSubscriptionId({ + streamKey, + channel, + symbol, + interval: intervalToken, + clientSubscriptionId: payload.clientSubscriptionId, + }) + const record: MarketSubscriptionRecord = { + subscriptionId, + clientSubscriptionId: payload.clientSubscriptionId, + streamKey, + listing, + socketId: socket.id, + socket, + symbol, + provider: payload.provider, + market, + channel, + interval: payload.interval, + listingBase: context.base, + listingQuote: context.quote, + } + + this.addSubscription(streamState, record) + + logger.info('Polling market subscription added', { + socketId: socket.id, + userId: socket.userId, + provider: payload.provider, + listing, + symbol, + channel, + }) + + return { + subscriptionId, + clientSubscriptionId: payload.clientSubscriptionId, + listing, + symbol, + provider: payload.provider, + market, + channel, + interval: payload.interval, + } + } + private addSubscription(streamState: StreamState, record: MarketSubscriptionRecord) { const symbolSubscribers = streamState.subscribersBySymbol.get(record.symbol) ?? new Map<string, MarketSubscriptionRecord>() - const hadChannel = Array.from(symbolSubscribers.values()).some( - (existing) => existing.channel === record.channel - ) + const hadUpstreamChannel = + record.upstreamChannel === undefined + ? true + : Array.from(symbolSubscribers.values()).some( + (existing) => existing.upstreamChannel === record.upstreamChannel + ) if (!symbolSubscribers.has(record.subscriptionId)) { symbolSubscribers.set(record.subscriptionId, record) streamState.subscribersBySymbol.set(record.symbol, symbolSubscribers) - if (!hadChannel) { - streamState.stream.subscribe([record.symbol], record.channel) + if (!hadUpstreamChannel && record.upstreamChannel) { + streamState.stream?.subscribe([record.symbol], record.upstreamChannel) } } const socketMap = this.socketSubscriptions.get(record.socketId) ?? new Map() socketMap.set(record.subscriptionId, record) this.socketSubscriptions.set(record.socketId, socketMap) + + if (record.channel === 'quote-snapshots') { + const cached = streamState.quoteSnapshotCache.get(record.symbol) + if (cached) { + this.emitQuoteSnapshot(record, cached) + } + } + + if (!streamState.stream) { + this.ensurePolling(streamState) + } } private getOrCreateStream( @@ -342,6 +515,8 @@ export class MarketStreamManager { keyId?: string secretKey?: string apiKey?: string + auth?: MarketProviderAuth + providerParams?: MarketProviderParams } ): StreamState { const existing = this.streams.get(streamKey) @@ -383,7 +558,35 @@ export class MarketStreamManager { market: config.market, feed: config.feed, cryptoRegion: config.cryptoRegion, - apiKey: config.apiKey, + auth: config.auth, + providerParams: config.providerParams, + quoteSnapshotCache: new Map(), + subscribersBySymbol: new Map(), + } + + this.streams.set(streamKey, state) + return state + } + + private getOrCreatePollingStream( + streamKey: string, + config: { + provider: PollingMarketProviderId + auth?: MarketProviderAuth + providerParams?: MarketProviderParams + pollingIntervalMs: number + } + ): StreamState { + const existing = this.streams.get(streamKey) + if (existing) return existing + + const state: StreamState = { + provider: config.provider, + market: 'stocks', + auth: config.auth, + providerParams: config.providerParams, + pollingIntervalMs: config.pollingIntervalMs, + quoteSnapshotCache: new Map(), subscribersBySymbol: new Map(), } @@ -424,7 +627,18 @@ export class MarketStreamManager { const subscribers = state.subscribersBySymbol.get(symbol) if (!subscribers || subscribers.size === 0) return + let quoteSnapshot: MarketQuoteSnapshot | null = null + subscribers.forEach((record) => { + if (record.channel === 'quote-snapshots') { + if (!quoteSnapshot) { + quoteSnapshot = updateSnapshotFromTrade(state.quoteSnapshotCache.get(symbol), trade) + state.quoteSnapshotCache.set(symbol, quoteSnapshot) + } + this.emitQuoteSnapshot(record, quoteSnapshot, raw) + return + } + if (record.channel !== 'trades') return record.socket.emit('market-trade', { provider: record.provider, @@ -469,6 +683,102 @@ export class MarketStreamManager { }) } + private emitQuoteSnapshotToSymbolSubscribers( + streamState: StreamState, + symbol: string, + snapshot: MarketQuoteSnapshot, + raw?: unknown + ) { + const subscribers = streamState.subscribersBySymbol.get(symbol) + if (!subscribers) return + + subscribers.forEach((record) => { + if (record.channel !== 'quote-snapshots') return + this.emitQuoteSnapshot(record, snapshot, raw) + }) + } + + private emitQuoteSnapshot( + record: MarketSubscriptionRecord, + snapshot: MarketQuoteSnapshot, + raw?: unknown + ) { + record.socket.emit('market-quote-snapshot', { + provider: record.provider, + market: record.market, + channel: record.channel, + subscriptionId: record.subscriptionId, + clientSubscriptionId: record.clientSubscriptionId, + listing: record.listing, + listingBase: record.listingBase, + listingQuote: record.listingQuote, + symbol: record.symbol, + interval: record.interval, + snapshot, + receivedAt: new Date().toISOString(), + raw, + }) + } + + private ensurePolling(streamState: StreamState) { + if (streamState.pollingTimer) return + const intervalMs = streamState.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS + streamState.pollingTimer = setInterval(() => { + void this.pollQuoteSnapshots(streamState) + }, intervalMs) + streamState.pollingTimer.unref?.() + void this.pollQuoteSnapshots(streamState) + } + + private async pollQuoteSnapshots(streamState: StreamState) { + if (streamState.pollingInFlight) return + + const records = new Map<string, MarketSubscriptionRecord>() + streamState.subscribersBySymbol.forEach((subscribers, symbol) => { + const record = Array.from(subscribers.values()).find( + (subscriber) => subscriber.channel === 'quote-snapshots' && subscriber.listing + ) + if (record) records.set(symbol, record) + }) + + if (records.size === 0) return + + streamState.pollingInFlight = true + try { + const pending = Array.from(records.entries()) + const workers = Array.from( + { length: Math.min(POLLING_CONCURRENCY, pending.length) }, + async () => { + while (pending.length > 0) { + const next = pending.shift() + if (!next) return + const [symbol, record] = next + try { + const snapshot = await buildMarketQuoteSnapshot({ + provider: record.provider, + listing: record.listing as ListingIdentity, + auth: streamState.auth, + providerParams: streamState.providerParams, + }) + streamState.quoteSnapshotCache.set(symbol, snapshot) + this.emitQuoteSnapshotToSymbolSubscribers(streamState, symbol, snapshot) + } catch (error) { + const snapshot = createEmptyMarketQuoteSnapshot( + error instanceof Error ? error.message : 'Failed to poll quote snapshot' + ) + streamState.quoteSnapshotCache.set(symbol, snapshot) + this.emitQuoteSnapshotToSymbolSubscribers(streamState, symbol, snapshot) + } + } + } + ) + + await Promise.all(workers) + } finally { + streamState.pollingInFlight = false + } + } + private handleStreamError(streamKey: string, message: string, detail?: any) { const state = this.streams.get(streamKey) if (!state) return @@ -480,6 +790,7 @@ export class MarketStreamManager { market: record.market, channel: record.channel, subscriptionId: record.subscriptionId, + clientSubscriptionId: record.clientSubscriptionId, message, detail, }) @@ -496,6 +807,14 @@ export class MarketStreamManager { return match ? [match] : [] } + if (payload.clientSubscriptionId) { + const matches: MarketSubscriptionRecord[] = [] + socketMap.forEach((record) => { + if (record.clientSubscriptionId === payload.clientSubscriptionId) matches.push(record) + }) + return matches + } + const symbol = payload.symbol ? normalizeSymbol(payload.symbol) : undefined const provider = payload.provider ? resolveProviderId(payload.provider) : undefined @@ -532,19 +851,25 @@ export class MarketStreamManager { symbolSubscribers.delete(record.subscriptionId) if (symbolSubscribers.size === 0) { streamState.subscribersBySymbol.delete(record.symbol) - streamState.stream.unsubscribe([record.symbol], record.channel) - } else { - const hasChannel = Array.from(symbolSubscribers.values()).some( - (existing) => existing.channel === record.channel + if (record.upstreamChannel) { + streamState.stream?.unsubscribe([record.symbol], record.upstreamChannel) + } + } else if (record.upstreamChannel) { + const hasUpstreamChannel = Array.from(symbolSubscribers.values()).some( + (existing) => existing.upstreamChannel === record.upstreamChannel ) - if (!hasChannel) { - streamState.stream.unsubscribe([record.symbol], record.channel) + if (!hasUpstreamChannel) { + streamState.stream?.unsubscribe([record.symbol], record.upstreamChannel) } } } if (streamState.subscribersBySymbol.size === 0) { - streamState.stream.close() + if (streamState.pollingTimer) { + clearInterval(streamState.pollingTimer) + streamState.pollingTimer = undefined + } + streamState.stream?.close() this.streams.delete(record.streamKey) } @@ -561,9 +886,16 @@ export class MarketStreamManager { export const marketStreamManager = new MarketStreamManager() -function resolveProviderId(provider?: MarketProviderId): MarketProviderId { +function resolveProviderId(provider?: AnyMarketProviderId): AnyMarketProviderId { if (provider === 'finnhub') return 'finnhub' - return 'alpaca' + if (provider === 'yahoo-finance') return 'yahoo-finance' + if (provider === 'alpha-vantage') return 'alpha-vantage' + if (provider === 'alpaca') return 'alpaca' + throw new Error('market provider is required') +} + +function resolveUpstreamChannel(channel: MarketChannel): MarketStreamChannel { + return channel === 'quote-snapshots' ? 'trades' : channel } function resolveMarket(payload: MarketSubscribePayload, assetClass?: string): AlpacaMarket { @@ -618,6 +950,7 @@ function resolveFinnhubApiKey(payload: MarketSubscribePayload): string | undefin function buildAlpacaStreamKey(config: { provider: MarketProviderId + workspaceId?: string market: AlpacaMarket feed?: AlpacaFeed cryptoRegion?: AlpacaCryptoRegion @@ -626,6 +959,7 @@ function buildAlpacaStreamKey(config: { }): string { const base = [ config.provider, + config.workspaceId ?? '', config.market, config.feed ?? '', config.cryptoRegion ?? '', @@ -636,11 +970,98 @@ function buildAlpacaStreamKey(config: { return createHash('sha256').update(base).digest('hex') } -function buildFinnhubStreamKey(config: { provider: MarketProviderId; apiKey: string }): string { - const base = [config.provider, config.apiKey].join('|') +function buildFinnhubStreamKey(config: { + provider: MarketProviderId + workspaceId?: string + apiKey: string +}): string { + const base = [config.provider, config.workspaceId ?? '', config.apiKey].join('|') return createHash('sha256').update(base).digest('hex') } +function buildPollingStreamKey(config: { + provider: PollingMarketProviderId + workspaceId?: string + auth?: MarketProviderAuth + providerParams?: MarketProviderParams +}): string { + const base = [ + config.provider, + config.workspaceId ?? '', + stableStringifyJsonValue(config.auth ?? null), + stableStringifyJsonValue(config.providerParams ?? null), + ].join('|') + return createHash('sha256').update(base).digest('hex') +} + +function createSubscriptionId({ + streamKey, + channel, + symbol, + interval, + clientSubscriptionId, +}: { + streamKey: string + channel: MarketChannel + symbol: string + interval: string + clientSubscriptionId?: string +}) { + return [ + streamKey, + channel, + symbol, + interval, + clientSubscriptionId?.trim() || randomUUID(), + ].join(':') +} + +function resolvePollingIntervalMs( + provider: PollingMarketProviderId, + providerParams?: MarketProviderParams +): number { + const configured = Number(providerParams?.pollingIntervalMs ?? providerParams?.pollIntervalMs) + const capabilityDefault = + getMarketLiveCapabilities(provider)?.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS + const requested = + Number.isFinite(configured) && configured > 0 ? configured : capabilityDefault + return Math.max(MIN_POLLING_INTERVAL_MS, requested) +} + +function updateSnapshotFromTrade( + previous: MarketQuoteSnapshot | undefined, + trade: any +): MarketQuoteSnapshot { + const price = resolveFiniteNumber(trade?.price) + if (price === null) return previous ?? createEmptyMarketQuoteSnapshot() + + const previousClose = previous?.previousClose ?? null + const change = + previousClose !== null + ? price - previousClose + : (previous?.change ?? null) + const changePercent = + previousClose !== null && previousClose !== 0 + ? ((price - previousClose) / previousClose) * 100 + : (previous?.changePercent ?? null) + const volume = previous?.volume ?? null + const volumeUsd = volume !== null ? volume * price : (previous?.volumeUsd ?? null) + + return { + lastPrice: price, + previousClose, + change, + changePercent, + ...(volume !== null ? { volume } : {}), + ...(volumeUsd !== null ? { volumeUsd } : {}), + ...(previous?.error ? { error: previous.error } : {}), + } +} + +function resolveFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + function normalizeSymbol(symbol?: string): string { if (!symbol) return '' return symbol.trim().toUpperCase() diff --git a/apps/tradinggoose/socket-server/trading/portfolio-manager.test.ts b/apps/tradinggoose/socket-server/trading/portfolio-manager.test.ts new file mode 100644 index 000000000..3cb2ca21f --- /dev/null +++ b/apps/tradinggoose/socket-server/trading/portfolio-manager.test.ts @@ -0,0 +1,340 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + getOAuthTokenMock, + getTradingProviderDefinitionMock, + getTradingProviderOAuthEnvironmentMock, + getTradingProviderOAuthServiceIdMock, + getTradingPortfolioSupportedWindowsMock, + isTradingPortfolioWindowSupportedMock, + listTradingAccountsMock, + getTradingAccountSnapshotMock, + getTradingAccountPerformanceMock, + resolveTradingPositionListingIdentityMock, +} = vi.hoisted(() => ({ + getOAuthTokenMock: vi.fn(), + getTradingProviderDefinitionMock: vi.fn(), + getTradingProviderOAuthServiceIdMock: vi.fn(), + getTradingProviderOAuthEnvironmentMock: vi.fn(), + getTradingPortfolioSupportedWindowsMock: vi.fn(), + isTradingPortfolioWindowSupportedMock: vi.fn(), + listTradingAccountsMock: vi.fn(), + getTradingAccountSnapshotMock: vi.fn(), + getTradingAccountPerformanceMock: vi.fn(), + resolveTradingPositionListingIdentityMock: vi.fn(), +})) + +vi.mock('@/app/api/auth/oauth/utils', () => ({ + getOAuthToken: (...args: unknown[]) => getOAuthTokenMock(...args), +})) + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), +})) + +vi.mock('@/providers/trading/listing-resolution', () => ({ + resolveTradingPositionListingIdentity: (...args: unknown[]) => + resolveTradingPositionListingIdentityMock(...args), +})) + +vi.mock('@/providers/trading/portfolio', () => ({ + getTradingAccountPerformance: (...args: unknown[]) => getTradingAccountPerformanceMock(...args), + getTradingAccountSnapshot: (...args: unknown[]) => getTradingAccountSnapshotMock(...args), + getTradingPortfolioSupportedWindows: (...args: unknown[]) => + getTradingPortfolioSupportedWindowsMock(...args), + isTradingPortfolioWindowSupported: (...args: unknown[]) => + isTradingPortfolioWindowSupportedMock(...args), + listTradingAccounts: (...args: unknown[]) => listTradingAccountsMock(...args), +})) + +vi.mock('@/providers/trading/providers', () => ({ + getTradingProviderDefinition: (...args: unknown[]) => getTradingProviderDefinitionMock(...args), + getTradingProviderOAuthEnvironment: (...args: unknown[]) => + getTradingProviderOAuthEnvironmentMock(...args), + getTradingProviderOAuthServiceId: (...args: unknown[]) => + getTradingProviderOAuthServiceIdMock(...args), +})) + +import { buildTradingPositionListings, TradingPortfolioStreamManager } from './portfolio-manager' + +const account = { + id: 'acct-1', + name: 'Primary', + type: 'paper' as const, + baseCurrency: 'USD', + status: 'active' as const, +} + +const snapshot = { + asOf: '2026-04-30T12:00:00.000Z', + provider: { name: 'Alpaca' }, + account: { + id: 'acct-1', + name: 'Primary', + type: 'paper' as const, + baseCurrency: 'USD', + status: 'active' as const, + }, + cashBalances: [], + positions: [ + { + symbol: { + base: 'AAPL', + quote: 'USD', + assetClass: 'stock' as const, + active: true, + rank: 0, + }, + quantity: 2, + }, + ], + orders: [], + accountSummary: { + totalPortfolioValue: 1000, + totalCashValue: 100, + }, +} + +const performance = { + window: '1D' as const, + supportedWindows: ['1D' as const], + series: [{ timestamp: '2026-04-30T12:00:00.000Z', equity: 1000 }], + summary: { + currency: 'USD', + startEquity: 900, + endEquity: 1000, + highEquity: 1000, + lowEquity: 900, + absoluteReturn: 100, + percentReturn: 11.11, + asOf: '2026-04-30T12:00:00.000Z', + }, +} + +const createSocket = (id: string) => + ({ + id, + userId: 'user-1', + emit: vi.fn(), + }) as any + +const flushPortfolioPolls = async () => { + for (let index = 0; index < 8; index += 1) { + await Promise.resolve() + } +} + +describe('TradingPortfolioStreamManager', () => { + beforeEach(() => { + vi.clearAllMocks() + getOAuthTokenMock.mockResolvedValue('oauth-token') + getTradingProviderDefinitionMock.mockReturnValue({ + id: 'alpaca', + name: 'Alpaca', + }) + getTradingProviderOAuthServiceIdMock.mockReturnValue('alpaca') + getTradingProviderOAuthEnvironmentMock.mockReturnValue('live') + getTradingPortfolioSupportedWindowsMock.mockReturnValue(['1D', '1W']) + isTradingPortfolioWindowSupportedMock.mockReturnValue(true) + listTradingAccountsMock.mockResolvedValue([account]) + getTradingAccountSnapshotMock.mockResolvedValue(snapshot) + getTradingAccountPerformanceMock.mockResolvedValue(performance) + resolveTradingPositionListingIdentityMock.mockResolvedValue({ + listing_id: 'TG_LSTG_AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('shares one snapshot poll for duplicate account snapshot subscribers', async () => { + vi.useFakeTimers() + const manager = new TradingPortfolioStreamManager() + const firstSocket = createSocket('socket-1') + const secondSocket = createSocket('socket-2') + + await manager.subscribe(firstSocket, { + provider: 'alpaca', + workspaceId: 'workspace-1', + accountId: 'acct-1', + channel: 'account-snapshot', + clientSubscriptionId: 'snapshot-1', + }) + await manager.subscribe(secondSocket, { + provider: 'alpaca', + workspaceId: 'workspace-1', + accountId: 'acct-1', + channel: 'account-snapshot', + clientSubscriptionId: 'snapshot-2', + }) + + await flushPortfolioPolls() + + expect(getOAuthTokenMock).toHaveBeenCalledTimes(1) + expect(listTradingAccountsMock).toHaveBeenCalledTimes(1) + expect(getTradingAccountSnapshotMock).toHaveBeenCalledTimes(1) + expect(getTradingAccountSnapshotMock).toHaveBeenCalledWith({ + providerId: 'alpaca', + environment: 'live', + accessToken: 'oauth-token', + accountId: 'acct-1', + }) + expect(firstSocket.emit).toHaveBeenCalledWith( + 'trading-portfolio-snapshot', + expect.objectContaining({ + provider: 'alpaca', + workspaceId: 'workspace-1', + channel: 'account-snapshot', + accountId: 'acct-1', + clientSubscriptionId: 'snapshot-1', + snapshot: expect.objectContaining({ account: expect.objectContaining({ id: 'acct-1' }) }), + positionListings: [ + expect.objectContaining({ + grossQuantity: 2, + signedQuantity: 2, + }), + ], + }) + ) + expect(secondSocket.emit).toHaveBeenCalledWith( + 'trading-portfolio-snapshot', + expect.objectContaining({ + clientSubscriptionId: 'snapshot-2', + }) + ) + + manager.removeSocket(firstSocket.id) + manager.removeSocket(secondSocket.id) + }) + + it('keeps duplicate client subscription ids isolated across sockets', async () => { + vi.useFakeTimers() + const manager = new TradingPortfolioStreamManager() + const firstSocket = createSocket('socket-1') + const secondSocket = createSocket('socket-2') + + const first = await manager.subscribe(firstSocket, { + provider: 'alpaca', + workspaceId: 'workspace-1', + accountId: 'acct-1', + channel: 'account-snapshot', + clientSubscriptionId: 'portfolio_snapshot', + }) + const second = await manager.subscribe(secondSocket, { + provider: 'alpaca', + workspaceId: 'workspace-1', + accountId: 'acct-1', + channel: 'account-snapshot', + clientSubscriptionId: 'portfolio_snapshot', + }) + + expect(first.subscriptionId).not.toBe(second.subscriptionId) + + await flushPortfolioPolls() + + expect(firstSocket.emit).toHaveBeenCalledWith( + 'trading-portfolio-snapshot', + expect.objectContaining({ + subscriptionId: first.subscriptionId, + clientSubscriptionId: 'portfolio_snapshot', + }) + ) + expect(secondSocket.emit).toHaveBeenCalledWith( + 'trading-portfolio-snapshot', + expect.objectContaining({ + subscriptionId: second.subscriptionId, + clientSubscriptionId: 'portfolio_snapshot', + }) + ) + + manager.removeSocket(firstSocket.id) + manager.removeSocket(secondSocket.id) + }) + + it('dedupes account pulls across snapshot and performance streams for the same account', async () => { + vi.useFakeTimers() + const manager = new TradingPortfolioStreamManager() + const socket = createSocket('socket-1') + + await manager.subscribe(socket, { + provider: 'alpaca', + workspaceId: 'workspace-1', + accountId: 'acct-1', + channel: 'account-snapshot', + clientSubscriptionId: 'snapshot-1', + }) + await manager.subscribe(socket, { + provider: 'alpaca', + workspaceId: 'workspace-1', + accountId: 'acct-1', + channel: 'portfolio-performance', + window: '1D', + clientSubscriptionId: 'performance-1', + }) + + await flushPortfolioPolls() + + expect(listTradingAccountsMock).toHaveBeenCalledTimes(1) + expect(getTradingAccountSnapshotMock).toHaveBeenCalledTimes(1) + expect(getTradingAccountPerformanceMock).toHaveBeenCalledTimes(1) + expect(socket.emit).toHaveBeenCalledWith( + 'trading-portfolio-performance', + expect.objectContaining({ + provider: 'alpaca', + workspaceId: 'workspace-1', + channel: 'portfolio-performance', + accountId: 'acct-1', + window: '1D', + performance, + }) + ) + + manager.removeSocket(socket.id) + }) +}) + +describe('buildTradingPositionListings', () => { + beforeEach(() => { + vi.clearAllMocks() + resolveTradingPositionListingIdentityMock.mockImplementation((symbol: { base: string }) => ({ + listing_id: `TG_LSTG_${symbol.base}`, + base_id: '', + quote_id: '', + listing_type: 'default', + })) + }) + + it('maps widget-local broker positions into canonical listing totals', async () => { + const listings = await buildTradingPositionListings({ + ...snapshot, + positions: [ + { ...snapshot.positions[0], quantity: 3, multiplier: 2 }, + { ...snapshot.positions[0], quantity: -1 }, + ], + }) + + expect(listings).toEqual([ + { + listing: { + listing_id: 'TG_LSTG_AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + grossQuantity: 7, + signedQuantity: 5, + }, + ]) + }) +}) diff --git a/apps/tradinggoose/socket-server/trading/portfolio-manager.ts b/apps/tradinggoose/socket-server/trading/portfolio-manager.ts new file mode 100644 index 000000000..74b654222 --- /dev/null +++ b/apps/tradinggoose/socket-server/trading/portfolio-manager.ts @@ -0,0 +1,753 @@ +import { createHash, randomUUID } from 'crypto' +import { getListingIdentityKey } from '@/lib/listing/identity' +import { createLogger } from '@/lib/logs/console/logger' +import { getOAuthToken } from '@/app/api/auth/oauth/utils' +import { resolveTradingPositionListingIdentity } from '@/providers/trading/listing-resolution' +import { + getTradingAccountPerformance, + getTradingAccountSnapshot, + getTradingPortfolioSupportedWindows, + isTradingPortfolioWindowSupported, + listTradingAccounts, +} from '@/providers/trading/portfolio' +import { TradingBrokerRequestError } from '@/providers/trading/portfolio-utils' +import { + getTradingProviderDefinition, + getTradingProviderOAuthEnvironment, + getTradingProviderOAuthServiceId, +} from '@/providers/trading/providers' +import type { + TradingPortfolioBaseContext, + TradingPortfolioPerformanceWindow, + TradingProviderId, + UnifiedTradingAccount, + UnifiedTradingAccountSnapshot, + UnifiedTradingPositionListings, +} from '@/providers/trading/types' +import type { AuthenticatedSocket } from '@/socket-server/middleware/auth' + +const logger = createLogger('TradingPortfolioStreamManager') + +const ACCOUNT_CACHE_TTL_MS = 60_000 +const CHANNEL_POLL_INTERVAL_MS: Record<TradingPortfolioChannel, number> = { + accounts: 60_000, + 'account-snapshot': 15_000, + 'portfolio-performance': 60_000, +} + +export type TradingPortfolioChannel = 'accounts' | 'account-snapshot' | 'portfolio-performance' + +export interface TradingPortfolioSubscribePayload { + provider?: string + credentialServiceId?: string + workspaceId?: string + accountId?: string + window?: TradingPortfolioPerformanceWindow + channel?: TradingPortfolioChannel + clientSubscriptionId?: string + forceRefresh?: boolean +} + +export interface TradingPortfolioUnsubscribePayload { + subscriptionId?: string + clientSubscriptionId?: string + provider?: string + credentialServiceId?: string + channel?: TradingPortfolioChannel + accountId?: string +} + +export interface TradingPortfolioSubscriptionInfo { + subscriptionId: string + clientSubscriptionId?: string + provider: TradingProviderId + credentialServiceId?: string + workspaceId: string + channel: TradingPortfolioChannel + accountId?: string + window?: TradingPortfolioPerformanceWindow +} + +interface TradingPortfolioSubscriptionRecord extends TradingPortfolioSubscriptionInfo { + streamKey: string + socketId: string + socket: AuthenticatedSocket +} + +interface TradingPortfolioStreamState { + streamKey: string + userId: string + workspaceId: string + providerId: TradingProviderId + credentialServiceId?: string + channel: TradingPortfolioChannel + accountId?: string + window?: TradingPortfolioPerformanceWindow + pollingTimer?: ReturnType<typeof setInterval> + pollingInFlight?: boolean + lastPayload?: TradingPortfolioDataPayload + subscribers: Map<string, TradingPortfolioSubscriptionRecord> +} + +interface AccountsCacheEntry { + data?: UnifiedTradingAccount[] + expiresAt: number + promise?: Promise<UnifiedTradingAccount[]> +} + +type TradingPortfolioBasePayload = { + provider: TradingProviderId + credentialServiceId?: string + workspaceId: string + channel: TradingPortfolioChannel + receivedAt: string +} + +type TradingPortfolioAccountsPayload = TradingPortfolioBasePayload & { + channel: 'accounts' + accounts: UnifiedTradingAccount[] +} + +type TradingPortfolioSnapshotPayload = TradingPortfolioBasePayload & { + channel: 'account-snapshot' + accountId: string + snapshot: UnifiedTradingAccountSnapshot + positionListings: UnifiedTradingPositionListings['positionListings'] +} + +type TradingPortfolioPerformancePayload = TradingPortfolioBasePayload & { + channel: 'portfolio-performance' + accountId: string + window: TradingPortfolioPerformanceWindow + performance: Awaited<ReturnType<typeof getTradingAccountPerformance>> +} + +type TradingPortfolioDataPayload = + | TradingPortfolioAccountsPayload + | TradingPortfolioSnapshotPayload + | TradingPortfolioPerformancePayload + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) + +export async function buildTradingPositionListings( + snapshot: UnifiedTradingAccountSnapshot +): Promise<UnifiedTradingPositionListings['positionListings']> { + const positionListingsByKey = new Map< + string, + UnifiedTradingPositionListings['positionListings'][number] + >() + + for (const position of snapshot.positions) { + const listing = await resolveTradingPositionListingIdentity(position.symbol) + if (!listing) continue + + const key = getListingIdentityKey(listing) + const multiplier = isFiniteNumber(position.multiplier) ? position.multiplier : 1 + const conversionRate = isFiniteNumber(position.conversionRate) ? position.conversionRate : 1 + const quantity = isFiniteNumber(position.quantity) ? position.quantity : 0 + const signedQuantity = quantity * multiplier * conversionRate + const grossQuantity = Math.abs(signedQuantity) + const current = positionListingsByKey.get(key) + + if (current) { + current.grossQuantity += grossQuantity + current.signedQuantity += signedQuantity + continue + } + + positionListingsByKey.set(key, { + listing, + grossQuantity, + signedQuantity, + }) + } + + return Array.from(positionListingsByKey.values()) +} + +export class TradingPortfolioStreamManager { + private streams = new Map<string, TradingPortfolioStreamState>() + private socketSubscriptions = new Map<string, Map<string, TradingPortfolioSubscriptionRecord>>() + private accountsCache = new Map<string, AccountsCacheEntry>() + + async subscribe( + socket: AuthenticatedSocket, + payload: TradingPortfolioSubscribePayload + ): Promise<TradingPortfolioSubscriptionInfo> { + const userId = socket.userId + if (!userId) throw new Error('Authentication required') + + const providerId = resolveTradingProviderId(payload.provider) + const workspaceId = resolveWorkspaceId(payload.workspaceId) + const channel = resolveChannel(payload.channel) + const credentialServiceId = resolveCredentialServiceId(providerId, payload.credentialServiceId) + const accountId = resolveAccountId(channel, payload.accountId) + const window = resolvePerformanceWindow(providerId, channel, payload.window) + const streamKey = buildStreamKey({ + userId, + workspaceId, + providerId, + credentialServiceId, + channel, + accountId, + window, + }) + const streamState = this.getOrCreateStreamState({ + streamKey, + userId, + workspaceId, + providerId, + credentialServiceId, + channel, + accountId, + window, + }) + const subscriptionId = createSubscriptionId({ + streamKey, + socketId: socket.id, + clientSubscriptionId: payload.clientSubscriptionId, + }) + const record: TradingPortfolioSubscriptionRecord = { + subscriptionId, + clientSubscriptionId: payload.clientSubscriptionId, + streamKey, + socketId: socket.id, + socket, + provider: providerId, + credentialServiceId, + workspaceId, + channel, + accountId, + window, + } + + streamState.subscribers.set(subscriptionId, record) + const socketMap = this.socketSubscriptions.get(socket.id) ?? new Map() + socketMap.set(subscriptionId, record) + this.socketSubscriptions.set(socket.id, socketMap) + + if (streamState.lastPayload) { + this.emitData(record, streamState.lastPayload) + } + + this.ensurePolling(streamState, Boolean(payload.forceRefresh)) + + logger.info('Trading portfolio subscription added', { + socketId: socket.id, + userId, + providerId, + credentialServiceId, + workspaceId, + channel, + accountId, + window, + }) + + return { + subscriptionId, + clientSubscriptionId: payload.clientSubscriptionId, + provider: providerId, + credentialServiceId, + workspaceId, + channel, + accountId, + window, + } + } + + unsubscribe( + socket: AuthenticatedSocket, + payload: TradingPortfolioUnsubscribePayload + ): TradingPortfolioSubscriptionInfo[] { + const socketMap = this.socketSubscriptions.get(socket.id) + if (!socketMap || socketMap.size === 0) return [] + + const matches = this.findMatchingSubscriptions(socketMap, payload) + matches.forEach((record) => this.removeRecord(record)) + + return matches.map(toSubscriptionInfo) + } + + refresh(socket: AuthenticatedSocket, payload: TradingPortfolioUnsubscribePayload) { + const socketMap = this.socketSubscriptions.get(socket.id) + if (!socketMap || socketMap.size === 0) return [] + + const matches = this.findMatchingSubscriptions(socketMap, payload) + const streamKeys = new Set(matches.map((record) => record.streamKey)) + streamKeys.forEach((streamKey) => { + const state = this.streams.get(streamKey) + if (state) void this.pollState(state, true) + }) + + return matches.map(toSubscriptionInfo) + } + + removeSocket(socketId: string) { + const socketMap = this.socketSubscriptions.get(socketId) + if (!socketMap) return + + socketMap.forEach((record) => this.removeRecord(record)) + } + + private getOrCreateStreamState( + config: Omit<TradingPortfolioStreamState, 'subscribers'> + ): TradingPortfolioStreamState { + const existing = this.streams.get(config.streamKey) + if (existing) return existing + + const next: TradingPortfolioStreamState = { + ...config, + subscribers: new Map(), + } + this.streams.set(config.streamKey, next) + return next + } + + private ensurePolling(streamState: TradingPortfolioStreamState, forceRefresh: boolean) { + if (!streamState.pollingTimer) { + const intervalMs = CHANNEL_POLL_INTERVAL_MS[streamState.channel] + streamState.pollingTimer = setInterval(() => { + void this.pollState(streamState, false) + }, intervalMs) + streamState.pollingTimer.unref?.() + } + + if (forceRefresh || !streamState.lastPayload) { + void this.pollState(streamState, forceRefresh) + } + } + + private async pollState(streamState: TradingPortfolioStreamState, forceRefresh: boolean) { + if (streamState.pollingInFlight) return + if (streamState.subscribers.size === 0) return + + streamState.pollingInFlight = true + try { + const context = await resolveTradingPortfolioContext(streamState) + + if (streamState.channel === 'accounts') { + const accounts = await this.getAccounts(streamState, context, forceRefresh) + const payload: TradingPortfolioAccountsPayload = { + provider: streamState.providerId, + credentialServiceId: streamState.credentialServiceId, + workspaceId: streamState.workspaceId, + channel: 'accounts', + accounts, + receivedAt: new Date().toISOString(), + } + streamState.lastPayload = payload + this.emitToSubscribers(streamState, payload) + return + } + + const account = await this.getSelectedAccount(streamState, context, forceRefresh) + + if (streamState.channel === 'account-snapshot') { + const rawSnapshot = await getTradingAccountSnapshot({ + providerId: context.providerId, + environment: context.environment, + accessToken: context.accessToken, + accountId: account.id, + }) + const snapshot = { + ...rawSnapshot, + account: mergeSnapshotAccountMetadata({ + snapshot: rawSnapshot, + selectedAccount: account, + }), + } + const positionListings = await buildTradingPositionListings(snapshot) + const payload: TradingPortfolioSnapshotPayload = { + provider: streamState.providerId, + credentialServiceId: streamState.credentialServiceId, + workspaceId: streamState.workspaceId, + channel: 'account-snapshot', + accountId: account.id, + snapshot, + positionListings, + receivedAt: new Date().toISOString(), + } + streamState.lastPayload = payload + this.emitToSubscribers(streamState, payload) + return + } + + if (!streamState.window) { + throw new Error('performance window is required') + } + + const performance = await getTradingAccountPerformance({ + providerId: context.providerId, + environment: context.environment, + accessToken: context.accessToken, + accountId: account.id, + window: streamState.window, + }) + const payload: TradingPortfolioPerformancePayload = { + provider: streamState.providerId, + credentialServiceId: streamState.credentialServiceId, + workspaceId: streamState.workspaceId, + channel: 'portfolio-performance', + accountId: account.id, + window: streamState.window, + performance, + receivedAt: new Date().toISOString(), + } + streamState.lastPayload = payload + this.emitToSubscribers(streamState, payload) + } catch (error) { + this.emitErrorToSubscribers(streamState, error) + } finally { + streamState.pollingInFlight = false + } + } + + private async getAccounts( + streamState: TradingPortfolioStreamState, + context: TradingPortfolioBaseContext, + forceRefresh: boolean + ): Promise<UnifiedTradingAccount[]> { + const cacheKey = buildAccountsCacheKey(streamState) + const cached = this.accountsCache.get(cacheKey) + const now = Date.now() + + if (!forceRefresh && cached?.data && cached.expiresAt > now) { + return cached.data + } + + if (!forceRefresh && cached?.promise) { + return cached.promise + } + + const promise = listTradingAccounts(context) + this.accountsCache.set(cacheKey, { + data: cached?.data, + expiresAt: cached?.expiresAt ?? 0, + promise, + }) + + try { + const data = await promise + this.accountsCache.set(cacheKey, { + data, + expiresAt: Date.now() + ACCOUNT_CACHE_TTL_MS, + }) + return data + } catch (error) { + if (cached?.data) { + this.accountsCache.set(cacheKey, cached) + } else { + this.accountsCache.delete(cacheKey) + } + throw error + } + } + + private async getSelectedAccount( + streamState: TradingPortfolioStreamState, + context: TradingPortfolioBaseContext, + forceRefresh: boolean + ) { + const accountId = streamState.accountId + if (!accountId) throw new Error('accountId is required') + + const accounts = await this.getAccounts(streamState, context, forceRefresh) + const account = accounts.find((candidate) => candidate.id === accountId) + if (!account) throw new Error('Account not found for provider connection') + return account + } + + private emitToSubscribers( + streamState: TradingPortfolioStreamState, + payload: TradingPortfolioDataPayload + ) { + streamState.subscribers.forEach((record) => this.emitData(record, payload)) + } + + private emitData( + record: TradingPortfolioSubscriptionRecord, + payload: TradingPortfolioDataPayload + ) { + const basePayload = { + ...payload, + subscriptionId: record.subscriptionId, + clientSubscriptionId: record.clientSubscriptionId, + } + + if (payload.channel === 'accounts') { + record.socket.emit('trading-portfolio-accounts', basePayload) + return + } + + if (payload.channel === 'account-snapshot') { + record.socket.emit('trading-portfolio-snapshot', basePayload) + return + } + + record.socket.emit('trading-portfolio-performance', basePayload) + } + + private emitErrorToSubscribers(streamState: TradingPortfolioStreamState, error: unknown) { + const message = error instanceof Error ? error.message : String(error) + + if (error instanceof TradingBrokerRequestError) { + logger.error('Trading portfolio broker request failed', { + providerId: error.providerId, + status: error.status, + url: error.url, + payload: error.payload, + error: error.message, + }) + } else { + logger.error('Trading portfolio poll failed', { + providerId: streamState.providerId, + channel: streamState.channel, + accountId: streamState.accountId, + error: message, + }) + } + + streamState.subscribers.forEach((record) => { + record.socket.emit('trading-portfolio-error', { + provider: record.provider, + credentialServiceId: record.credentialServiceId, + workspaceId: record.workspaceId, + channel: record.channel, + accountId: record.accountId, + window: record.window, + subscriptionId: record.subscriptionId, + clientSubscriptionId: record.clientSubscriptionId, + message, + }) + }) + } + + private findMatchingSubscriptions( + socketMap: Map<string, TradingPortfolioSubscriptionRecord>, + payload: TradingPortfolioUnsubscribePayload + ): TradingPortfolioSubscriptionRecord[] { + if (payload.subscriptionId) { + const match = socketMap.get(payload.subscriptionId) + return match ? [match] : [] + } + + if (payload.clientSubscriptionId) { + const matches: TradingPortfolioSubscriptionRecord[] = [] + socketMap.forEach((record) => { + if (record.clientSubscriptionId === payload.clientSubscriptionId) matches.push(record) + }) + return matches + } + + const providerId = payload.provider?.trim() + const credentialServiceId = payload.credentialServiceId?.trim() + const matches: TradingPortfolioSubscriptionRecord[] = [] + socketMap.forEach((record) => { + if (providerId && record.provider !== providerId) return + if (credentialServiceId && record.credentialServiceId !== credentialServiceId) return + if (payload.channel && record.channel !== payload.channel) return + if (payload.accountId && record.accountId !== payload.accountId.trim()) return + matches.push(record) + }) + return matches + } + + private removeRecord(record: TradingPortfolioSubscriptionRecord) { + const socketMap = this.socketSubscriptions.get(record.socketId) + if (socketMap) { + socketMap.delete(record.subscriptionId) + if (socketMap.size === 0) { + this.socketSubscriptions.delete(record.socketId) + } + } + + const streamState = this.streams.get(record.streamKey) + if (!streamState) return + + streamState.subscribers.delete(record.subscriptionId) + if (streamState.subscribers.size === 0) { + if (streamState.pollingTimer) { + clearInterval(streamState.pollingTimer) + } + this.streams.delete(record.streamKey) + } + + logger.info('Trading portfolio subscription removed', { + socketId: record.socketId, + userId: record.socket.userId, + provider: record.provider, + credentialServiceId: record.credentialServiceId, + workspaceId: record.workspaceId, + channel: record.channel, + accountId: record.accountId, + window: record.window, + }) + } +} + +export const tradingPortfolioStreamManager = new TradingPortfolioStreamManager() + +async function resolveTradingPortfolioContext( + streamState: TradingPortfolioStreamState +): Promise<TradingPortfolioBaseContext> { + const providerDefinition = getTradingProviderDefinition(streamState.providerId) + if (!providerDefinition) throw new Error('Unsupported trading provider') + + const serviceId = getTradingProviderOAuthServiceId( + streamState.providerId, + streamState.credentialServiceId + ) + if (!serviceId) throw new Error('Trading provider OAuth service is not configured') + + const accessToken = await getOAuthToken(streamState.userId, serviceId) + if (!accessToken) throw new Error('Trading provider connection not found') + const environment = getTradingProviderOAuthEnvironment(streamState.providerId, serviceId) + if (!environment) throw new Error('Trading provider connection is not configured') + + return { + providerId: streamState.providerId, + environment, + accessToken, + } +} + +function resolveTradingProviderId(provider?: string): TradingProviderId { + const providerId = provider?.trim() + if (!providerId) throw new Error('trading provider is required') + if (!getTradingProviderDefinition(providerId)) { + throw new Error('Unsupported trading provider') + } + return providerId as TradingProviderId +} + +function resolveWorkspaceId(workspaceId?: string) { + const trimmed = workspaceId?.trim() + if (!trimmed) throw new Error('workspaceId is required') + return trimmed +} + +function resolveCredentialServiceId(providerId: TradingProviderId, credentialServiceId?: string) { + const serviceId = getTradingProviderOAuthServiceId(providerId, credentialServiceId) + if (!serviceId) throw new Error('Trading provider connection is required') + return serviceId +} + +function resolveChannel(channel?: TradingPortfolioChannel): TradingPortfolioChannel { + if (!channel) return 'account-snapshot' + if ( + channel === 'accounts' || + channel === 'account-snapshot' || + channel === 'portfolio-performance' + ) { + return channel + } + throw new Error('Unsupported trading portfolio channel') +} + +function resolveAccountId(channel: TradingPortfolioChannel, accountId?: string) { + if (channel === 'accounts') return undefined + const trimmed = accountId?.trim() + if (!trimmed) throw new Error('accountId is required') + return trimmed +} + +function resolvePerformanceWindow( + providerId: TradingProviderId, + channel: TradingPortfolioChannel, + window?: TradingPortfolioPerformanceWindow +) { + if (channel !== 'portfolio-performance') return undefined + const candidate = window?.trim() as TradingPortfolioPerformanceWindow | undefined + const supportedWindows = getTradingPortfolioSupportedWindows(providerId) + const resolvedWindow = candidate || supportedWindows[0] + if (!resolvedWindow) throw new Error('performance window is required') + if (!isTradingPortfolioWindowSupported(providerId, resolvedWindow)) { + throw new Error('Unsupported performance window') + } + return resolvedWindow +} + +function buildStreamKey(config: { + userId: string + workspaceId: string + providerId: TradingProviderId + credentialServiceId?: string + channel: TradingPortfolioChannel + accountId?: string + window?: TradingPortfolioPerformanceWindow +}) { + return createHash('sha256') + .update( + [ + config.userId, + config.workspaceId, + config.providerId, + config.credentialServiceId ?? '', + config.channel, + config.accountId ?? '', + config.window ?? '', + ].join('|') + ) + .digest('hex') +} + +function buildAccountsCacheKey(streamState: TradingPortfolioStreamState) { + return createHash('sha256') + .update( + [ + streamState.userId, + streamState.workspaceId, + streamState.providerId, + streamState.credentialServiceId ?? '', + ].join('|') + ) + .digest('hex') +} + +function createSubscriptionId({ + streamKey, + socketId, + clientSubscriptionId, +}: { + streamKey: string + socketId: string + clientSubscriptionId?: string +}) { + return [streamKey, socketId, clientSubscriptionId?.trim() || randomUUID()].join(':') +} + +function mergeSnapshotAccountMetadata({ + snapshot, + selectedAccount, +}: { + snapshot: UnifiedTradingAccountSnapshot + selectedAccount: UnifiedTradingAccount +}) { + return { + ...snapshot.account, + id: selectedAccount.id, + name: snapshot.account.name ?? selectedAccount.name, + type: snapshot.account.type === 'unknown' ? selectedAccount.type : snapshot.account.type, + baseCurrency: snapshot.account.baseCurrency || selectedAccount.baseCurrency, + status: + !snapshot.account.status || snapshot.account.status === 'unknown' + ? selectedAccount.status + : snapshot.account.status, + } +} + +function toSubscriptionInfo( + record: TradingPortfolioSubscriptionRecord +): TradingPortfolioSubscriptionInfo { + return { + subscriptionId: record.subscriptionId, + clientSubscriptionId: record.clientSubscriptionId, + provider: record.provider, + credentialServiceId: record.credentialServiceId, + workspaceId: record.workspaceId, + channel: record.channel, + accountId: record.accountId, + window: record.window, + } +} diff --git a/apps/tradinggoose/stores/copilot/store-messages.test.ts b/apps/tradinggoose/stores/copilot/store-messages.test.ts new file mode 100644 index 000000000..cba14a911 --- /dev/null +++ b/apps/tradinggoose/stores/copilot/store-messages.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' +import { normalizeMessagesForUI } from './store-messages' +import type { CopilotMessage } from './types' + +describe('normalizeMessagesForUI', () => { + it('moves reasoning-only JSON prefixes out of assistant text content', () => { + const [message] = normalizeMessagesForUI([ + { + id: 'assistant-1', + role: 'assistant', + content: `${JSON.stringify({ reasoning: 'Internal reasoning.' })}\n\nVisible reply.`, + timestamp: '2026-04-28T00:00:00.000Z', + }, + ]) + + expect(message.content).toBe('Visible reply.') + expect(message.contentBlocks).toMatchObject([ + { + type: 'thinking', + content: 'Internal reasoning.', + }, + { + type: 'text', + content: 'Visible reply.', + }, + ]) + }) + + it('normalizes persisted assistant text blocks with reasoning JSON prefixes', () => { + const [message] = normalizeMessagesForUI([ + { + id: 'assistant-2', + role: 'assistant', + content: '', + timestamp: '2026-04-28T00:00:00.000Z', + contentBlocks: [ + { + type: 'text', + content: `${JSON.stringify({ reasoning: 'Block reasoning.' })}\n\nBlock reply.`, + timestamp: 1, + itemId: 'text-1', + }, + ], + } satisfies CopilotMessage, + ]) + + expect(message.contentBlocks).toMatchObject([ + { + type: 'thinking', + content: 'Block reasoning.', + itemId: 'text-1-reasoning', + }, + { + type: 'text', + content: 'Block reply.', + itemId: 'text-1', + }, + ]) + }) + + it('uses explicit reply text from full JSON assistant envelopes', () => { + const [message] = normalizeMessagesForUI([ + { + id: 'assistant-3', + role: 'assistant', + content: JSON.stringify({ + reasoning: 'Envelope reasoning.', + reply: 'Envelope reply.', + }), + timestamp: '2026-04-28T00:00:00.000Z', + }, + ]) + + expect(message.content).toBe('Envelope reply.') + expect(message.contentBlocks).toMatchObject([ + { + type: 'thinking', + content: 'Envelope reasoning.', + }, + { + type: 'text', + content: 'Envelope reply.', + }, + ]) + }) + + it('preserves content-level reasoning when assistant content blocks already exist', () => { + const input = { + id: 'assistant-4', + role: 'assistant', + content: `${JSON.stringify({ reasoning: 'Content reasoning.' })}\n\nVisible reply.`, + timestamp: '2026-04-28T00:00:00.000Z', + contentBlocks: [ + { + type: 'tool_call', + timestamp: 1, + toolCall: { + id: 'tool-1', + name: 'get_user_workflow', + state: ClientToolCallState.success, + }, + }, + ], + } satisfies CopilotMessage + const [message] = normalizeMessagesForUI([input]) + const [normalizedAgain] = normalizeMessagesForUI([input]) + + expect(message.content).toBe('Visible reply.') + expect(message.contentBlocks).toMatchObject([ + { + type: 'thinking', + content: 'Content reasoning.', + timestamp: Date.parse(input.timestamp), + }, + { + type: 'tool_call', + toolCall: { + id: 'tool-1', + }, + }, + ]) + expect((normalizedAgain.contentBlocks?.[0] as any)?.timestamp).toBe( + (message.contentBlocks?.[0] as any)?.timestamp + ) + }) +}) diff --git a/apps/tradinggoose/stores/copilot/store-messages.ts b/apps/tradinggoose/stores/copilot/store-messages.ts index 470387020..d38a39e45 100644 --- a/apps/tradinggoose/stores/copilot/store-messages.ts +++ b/apps/tradinggoose/stores/copilot/store-messages.ts @@ -15,6 +15,120 @@ import type { MessageFileAttachment, } from '@/stores/copilot/types' +function parseJsonObjectPrefix( + value: string +): { object: Record<string, unknown>; rest: string } | null { + let inString = false + let escaped = false + let depth = 0 + const start = value.search(/\S/) + if (start < 0 || value[start] !== '{') return null + + for (let index = start; index < value.length; index++) { + const char = value[index] + + if (inString) { + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === '"') { + inString = false + } + continue + } + + if (char === '"') { + inString = true + continue + } + + if (char === '{') { + depth += 1 + continue + } + + if (char !== '}') continue + depth -= 1 + if (depth !== 0) continue + + try { + const parsed = JSON.parse(value.slice(start, index + 1)) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + return { + object: parsed as Record<string, unknown>, + rest: value.slice(index + 1), + } + } catch { + return null + } + } + + return null +} + +function normalizeAssistantReasoningContent(content: string): { + content: string + reasoning?: string +} { + const parsed = parseJsonObjectPrefix(content) + if (!parsed || typeof parsed.object.reasoning !== 'string') { + return { content } + } + + const explicitContent = + typeof parsed.object.reply === 'string' + ? parsed.object.reply + : typeof parsed.object.content === 'string' + ? parsed.object.content + : typeof parsed.object.message === 'string' + ? parsed.object.message + : typeof parsed.object.text === 'string' + ? parsed.object.text + : undefined + + return { + content: (explicitContent ?? parsed.rest).trim(), + reasoning: parsed.object.reasoning.trim(), + } +} + +function normalizeAssistantContentBlocks(blocks: any[]): any[] { + return blocks.flatMap((block) => { + if (block?.type !== 'text' || typeof block.content !== 'string') { + return [block] + } + + const normalized = normalizeAssistantReasoningContent(block.content) + if (!normalized.reasoning) { + return [block] + } + + const timestamp = typeof block.timestamp === 'number' ? block.timestamp : Date.now() + return [ + { + type: 'thinking', + content: normalized.reasoning, + timestamp, + ...(typeof block.itemId === 'string' ? { itemId: `${block.itemId}-reasoning` } : {}), + }, + ...(normalized.content + ? [ + { + ...block, + content: normalized.content, + }, + ] + : []), + ] + }) +} + +function getMessageBlockTimestamp(message: CopilotMessage): number { + const timestamp = Date.parse(message.timestamp) + return Number.isFinite(timestamp) ? timestamp : Date.now() +} + export function normalizeMessagesForUI( messages: CopilotMessage[], latestTurnStatus?: string | null, @@ -35,7 +149,9 @@ export function normalizeMessagesForUI( return message } - const blocks: any[] = Array.isArray(message.contentBlocks) + const normalizedContent = normalizeAssistantReasoningContent(message.content || '') + const messageBlockTimestamp = getMessageBlockTimestamp(message) + const hydratedBlocks: any[] = Array.isArray(message.contentBlocks) ? (message.contentBlocks as any[]).map((b: any) => { if (b?.type === 'tool_call' && b.toolCall) { const normalizedToolCall = { @@ -73,6 +189,16 @@ export function normalizeMessagesForUI( return b }) : [] + const blocks = normalizeAssistantContentBlocks(hydratedBlocks) + const reasoningBlock = + normalizedContent.reasoning && !blocks.some((block: any) => block?.type === 'thinking') + ? { + type: 'thinking', + content: normalizedContent.reasoning, + timestamp: messageBlockTimestamp, + } + : null + const finalBlocks = reasoningBlock && blocks.length > 0 ? [reasoningBlock, ...blocks] : blocks const updatedToolCalls = Array.isArray((message as any).toolCalls) ? (message as any).toolCalls.map((tc: any) => { @@ -109,11 +235,25 @@ export function normalizeMessagesForUI( return { ...message, + content: normalizedContent.content, ...(updatedToolCalls && { toolCalls: updatedToolCalls }), - ...(blocks.length > 0 - ? { contentBlocks: blocks } - : message.content?.trim() - ? { contentBlocks: [{ type: 'text', content: message.content, timestamp: Date.now() }] } + ...(finalBlocks.length > 0 + ? { contentBlocks: finalBlocks } + : normalizedContent.reasoning || normalizedContent.content.trim() + ? { + contentBlocks: [ + ...(reasoningBlock ? [reasoningBlock] : []), + ...(normalizedContent.content.trim() + ? [ + { + type: 'text', + content: normalizedContent.content, + timestamp: messageBlockTimestamp, + }, + ] + : []), + ], + } : {}), } }) diff --git a/apps/tradinggoose/stores/copilot/store-state.ts b/apps/tradinggoose/stores/copilot/store-state.ts index 1b137eb42..5231c50d5 100644 --- a/apps/tradinggoose/stores/copilot/store-state.ts +++ b/apps/tradinggoose/stores/copilot/store-state.ts @@ -1,6 +1,7 @@ 'use client' import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' +import { shouldRequireCopilotApproval } from '@/lib/copilot/access-policy' import { copilotToolSupportsState } from '@/stores/copilot/tool-registry' import type { CopilotChat, CopilotStore, CopilotToolCall } from '@/stores/copilot/types' @@ -30,14 +31,14 @@ export function normalizeReloadedToolState( ? (state as ClientToolCallState) : ClientToolCallState.rejected - if ( - nextState === ClientToolCallState.generating || - nextState === ClientToolCallState.executing - ) { + if (nextState === ClientToolCallState.generating || nextState === ClientToolCallState.executing) { if (latestTurnStatus === ACTIVE_TURN_STATUS) { return nextState } - if (accessLevel !== 'full' && nextState === ClientToolCallState.executing) { + if ( + shouldRequireCopilotApproval(accessLevel ?? 'limited') && + nextState === ClientToolCallState.executing + ) { if (copilotToolSupportsState(toolName, ClientToolCallState.review)) { return ClientToolCallState.review } @@ -54,21 +55,15 @@ export function isChatTurnInProgress( return chat?.latestTurnStatus === ACTIVE_TURN_STATUS } -export function isToolCallRuntimeActive( - state: CopilotToolCall['state'] | undefined -): boolean { +export function isToolCallRuntimeActive(state: CopilotToolCall['state'] | undefined): boolean { return state != null && RUNTIME_ACTIVE_TOOL_STATES.has(state) } -export function isToolCallUiActive( - state: CopilotToolCall['state'] | undefined -): boolean { +export function isToolCallUiActive(state: CopilotToolCall['state'] | undefined): boolean { return state != null && UI_ACTIVE_TOOL_STATES.has(state) } -export function hasUiActiveToolCalls( - toolCallsById: Record<string, CopilotToolCall> -): boolean { +export function hasUiActiveToolCalls(toolCallsById: Record<string, CopilotToolCall>): boolean { return Object.values(toolCallsById).some((toolCall) => isToolCallUiActive(toolCall.state)) } @@ -109,5 +104,7 @@ export function resolveStoreTurnActivityState( state: Pick<CopilotStore, 'isAwaitingContinuation'>, toolCallsById: Record<string, CopilotToolCall> ): string { - return state.isAwaitingContinuation ? ACTIVE_TURN_STATUS : resolveTurnStatusFromToolCalls(toolCallsById) + return state.isAwaitingContinuation + ? ACTIVE_TURN_STATUS + : resolveTurnStatusFromToolCalls(toolCallsById) } diff --git a/apps/tradinggoose/stores/copilot/store.test.ts b/apps/tradinggoose/stores/copilot/store.test.ts index 825da4aa8..18e19241c 100644 --- a/apps/tradinggoose/stores/copilot/store.test.ts +++ b/apps/tradinggoose/stores/copilot/store.test.ts @@ -1742,7 +1742,7 @@ describe('copilot streaming regressions', () => { it('merges explicit and live implicit contexts before sending a message', async () => { const channelId = 'copilot-implicit-contexts' const store = getCopilotStore(channelId) - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString() if (url === '/api/copilot/chat') { return { @@ -2673,7 +2673,7 @@ describe('copilot context usage', () => { it('fetches context usage for a generic chat without workflow context', async () => { const channelId = 'copilot-context-usage-generic' const store = getCopilotStore(channelId) - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString() if (url === '/api/copilot/usage') { return { diff --git a/apps/tradinggoose/stores/copilot/store.ts b/apps/tradinggoose/stores/copilot/store.ts index 99f0f11f7..5a00154d6 100644 --- a/apps/tradinggoose/stores/copilot/store.ts +++ b/apps/tradinggoose/stores/copilot/store.ts @@ -7,6 +7,7 @@ import { createWithEqualityFn as create, useStoreWithEqualityFn } from 'zustand/ import { shouldAutoExecuteCopilotTool, shouldAutoExecuteIntegrationTool, + shouldRequireCopilotApproval, } from '@/lib/copilot/access-policy' import { type CopilotChat, sendStreamingMessage } from '@/lib/copilot/api' import { mergeCopilotContexts } from '@/lib/copilot/chat-contexts' @@ -265,7 +266,7 @@ function autoExecuteEligibleToolsForAccessLevel( accessLevel: CopilotStore['accessLevel'], get: () => CopilotStore ) { - if (accessLevel !== 'full') { + if (shouldRequireCopilotApproval(accessLevel)) { return } @@ -388,7 +389,8 @@ function syncCopilotSessionState(sourceStore: StoreApi<CopilotStore>) { for (const store of copilotStoreRegistry.values()) { if ( store === sourceStore || - store.getState().currentChat?.reviewSessionId !== sharedSessionState.currentChat.reviewSessionId + store.getState().currentChat?.reviewSessionId !== + sharedSessionState.currentChat.reviewSessionId ) { continue } @@ -728,12 +730,12 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) const preferredChat = typeof preferredWorkspaceSelection === 'string' ? (data.chats.find( - (chat: CopilotChat) => - chat.reviewSessionId === preferredWorkspaceSelection + (chat: CopilotChat) => chat.reviewSessionId === preferredWorkspaceSelection ) ?? null) : null const availableChat = - preferredChat ?? (preferredWorkspaceSelection === null ? null : (data.chats[0] ?? null)) + preferredChat ?? + (preferredWorkspaceSelection === null ? null : (data.chats[0] ?? null)) if (availableChat) { const normalizedMessages = normalizeMessagesForUI( @@ -745,7 +747,10 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) workspaceId: availableChat.workspaceId, }) - rememberCopilotWorkspaceSelection(availableChat.workspaceId, availableChat.reviewSessionId) + rememberCopilotWorkspaceSelection( + availableChat.workspaceId, + availableChat.reviewSessionId + ) set({ currentChat: availableChat, @@ -794,12 +799,7 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) // Send a message (streaming only) sendMessage: async (message: string, options = {}) => { const { currentChat } = get() - const { - fileAttachments, - contexts, - messageId, - runtimeContext, - } = options as { + const { fileAttachments, contexts, messageId, runtimeContext } = options as { fileAttachments?: MessageFileAttachment[] contexts?: ChatContext[] messageId?: string @@ -1045,133 +1045,6 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) set({ toolCallsById: map }) } catch {} }, - updatePreviewToolCallState: ( - toolCallState: 'accepted' | 'rejected' | 'error', - toolCallId?: string - ) => { - const stateMap: Record<string, ClientToolCallState> = { - accepted: ClientToolCallState.success, - rejected: ClientToolCallState.rejected, - error: ClientToolCallState.error, - } - const targetState = stateMap[toolCallState] || ClientToolCallState.success - const { toolCallsById } = get() - // Determine target tool - let id = toolCallId - if (!id) { - // Prefer the latest assistant message's build/edit tool_call - const messages = get().messages - outer: for (let mi = messages.length - 1; mi >= 0; mi--) { - const m = messages[mi] - if (m.role !== 'assistant' || !m.contentBlocks) continue - const blocks = m.contentBlocks as any[] - for (let bi = blocks.length - 1; bi >= 0; bi--) { - const b = blocks[bi] - if (b?.type === 'tool_call') { - const tn = b.toolCall?.name - if (tn === 'edit_workflow') { - id = b.toolCall?.id - break outer - } - } - } - } - // Fallback to map if not found in messages - if (!id) { - const candidates = Object.values(toolCallsById).filter( - (t) => t.name === 'edit_workflow' - ) - id = candidates.length ? candidates[candidates.length - 1].id : undefined - } - } - if (!id) return - const current = toolCallsById[id] - if (!current) return - // Do not override a rejected tool with success - if (isRejectedState(current.state) && targetState === ClientToolCallState.success) { - return - } - - // Update store map - const updatedMap = { ...toolCallsById } - const updatedDisplay = resolveToolDisplay(current.name, targetState, id, current.params) - updatedMap[id] = { - ...current, - state: targetState, - display: updatedDisplay, - } - set({ toolCallsById: updatedMap }) - - // Update inline content block in the latest assistant message - set((s) => { - const messages = [...s.messages] - for (let mi = messages.length - 1; mi >= 0; mi--) { - const m = messages[mi] - if (m.role !== 'assistant' || !m.contentBlocks) continue - let changed = false - const blocks = m.contentBlocks.map((b: any) => { - if (b.type === 'tool_call' && b.toolCall?.id === id) { - changed = true - const prev = b.toolCall || {} - return { - ...b, - toolCall: { - ...prev, - id, - name: current.name, - state: targetState, - display: updatedDisplay, - params: current.params, - }, - } - } - return b - }) - if (changed) { - messages[mi] = { ...m, contentBlocks: blocks } - break - } - } - return { messages } - }) - - // Notify backend mark-complete to finalize tool server-side - try { - postCopilotMarkComplete({ - toolCallId: id, - toolName: current.name, - status: - targetState === ClientToolCallState.success - ? 200 - : targetState === ClientToolCallState.rejected - ? REJECTED_TOOL_COMPLETION_STATUS - : 500, - message: toolCallState, - ...(targetState === ClientToolCallState.rejected ? { data: { rejected: true } } : {}), - }) - .then(async (res) => { - if (!res.ok) { - let body: string | undefined - try { - body = await res.text() - } catch {} - logger.warn('[mark-complete] proxy responded non-OK', { - toolCallId: id, - toolName: current.name, - status: res.status, - body: body?.slice(0, 200), - }) - } - }) - .catch((error) => { - logger.warn('[mark-complete] proxy fetch failed', { - toolCallId: id, - toolName: current.name, - error: error instanceof Error ? error.message : String(error), - }) - }) - } catch {} - }, saveChatMessages: async (chatId: string, options) => { const { currentChat, messages } = get() diff --git a/apps/tradinggoose/stores/copilot/streaming.ts b/apps/tradinggoose/stores/copilot/streaming.ts index 80845ea57..5ad981bf8 100644 --- a/apps/tradinggoose/stores/copilot/streaming.ts +++ b/apps/tradinggoose/stores/copilot/streaming.ts @@ -1,6 +1,7 @@ import { shouldAutoExecuteCopilotTool, shouldAutoExecuteIntegrationTool, + shouldRequireCopilotApproval, } from '@/lib/copilot/access-policy' import { normalizeFunctionCallArguments } from '@/lib/copilot/function-call-args' import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' @@ -62,11 +63,7 @@ function createOptimizedContentBlocks(contentBlocks: any[]): any[] { return result } -function applyStreamingTurnState( - set: any, - status: string, - isAwaitingContinuation: boolean -) { +function applyStreamingTurnState(set: any, status: string, isAwaitingContinuation: boolean) { set((state: CopilotStore) => ({ ...buildChatTurnStatusState(state, status), isSendingMessage: status === ACTIVE_TURN_STATUS, @@ -270,10 +267,7 @@ function scheduleAutomaticToolExecution( if (isCopilotTool(toolName)) { try { const hasInterrupt = copilotToolHasInterrupt(toolName, toolCallId) - const entersReviewState = copilotToolSupportsState( - toolName, - ClientToolCallState.review - ) + const entersReviewState = copilotToolSupportsState(toolName, ClientToolCallState.review) const { accessLevel } = get() if (shouldAutoExecuteCopilotTool(accessLevel, hasInterrupt, entersReviewState)) { setTimeout(() => { @@ -344,7 +338,7 @@ export async function flushPendingAutoExecutionToolCalls( } if ( - accessLevel !== 'full' && + shouldRequireCopilotApproval(accessLevel) && isCopilotTool(toolCall.name) && copilotToolSupportsState(toolCall.name, ClientToolCallState.review) ) { @@ -649,7 +643,11 @@ export function createSSEHandlers(params: { }, stream_end: (_data, context, _get, set) => { for (const block of context.contentBlocks as any[]) { - if (block?.type === THINKING_BLOCK_TYPE && block.startTime && block.duration === undefined) { + if ( + block?.type === THINKING_BLOCK_TYPE && + block.startTime && + block.duration === undefined + ) { block.duration = Date.now() - block.startTime } } diff --git a/apps/tradinggoose/stores/copilot/tool-registry.ts b/apps/tradinggoose/stores/copilot/tool-registry.ts index a940d7632..acf930065 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.ts @@ -6,9 +6,6 @@ import { type ClientToolDisplay, type ClientToolExecutionContext, } from '@/lib/copilot/tools/client/base-tool' -import { GetBlocksAndToolsClientTool } from '@/lib/copilot/tools/client/blocks/get-blocks-and-tools' -import { GetBlocksMetadataClientTool } from '@/lib/copilot/tools/client/blocks/get-blocks-metadata' -import { GetTriggerBlocksClientTool } from '@/lib/copilot/tools/client/blocks/get-trigger-blocks' import { CreateCustomToolClientTool, CreateIndicatorClientTool, @@ -31,28 +28,18 @@ import { RenameMcpServerClientTool, RenameSkillClientTool, } from '@/lib/copilot/tools/client/entities/entity-document-tools' -import { ListGDriveFilesClientTool } from '@/lib/copilot/tools/client/gdrive/list-files' -import { ReadGDriveFileClientTool } from '@/lib/copilot/tools/client/gdrive/read-file' import { GDriveRequestAccessClientTool } from '@/lib/copilot/tools/client/google/gdrive-request-access' -import { GetIndicatorCatalogClientTool } from '@/lib/copilot/tools/client/indicators/get-indicator-catalog' -import { GetIndicatorMetadataClientTool } from '@/lib/copilot/tools/client/indicators/get-indicator-metadata' import { KnowledgeBaseClientTool } from '@/lib/copilot/tools/client/knowledge/knowledge-base' import { getClientTool, registerClientTool } from '@/lib/copilot/tools/client/manager' import { EditMonitorClientTool } from '@/lib/copilot/tools/client/monitor/edit-monitor' import { GetMonitorClientTool } from '@/lib/copilot/tools/client/monitor/get-monitor' import { ListMonitorsClientTool } from '@/lib/copilot/tools/client/monitor/list-monitors' import { CheckoffTodoClientTool } from '@/lib/copilot/tools/client/other/checkoff-todo' -import { MakeApiRequestClientTool } from '@/lib/copilot/tools/client/other/make-api-request' import { MarkTodoInProgressClientTool } from '@/lib/copilot/tools/client/other/mark-todo-in-progress' import { OAuthRequestAccessClientTool } from '@/lib/copilot/tools/client/other/oauth-request-access' import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan' -import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation' -import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online' import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep' -import { GetCredentialsClientTool } from '@/lib/copilot/tools/client/user/get-credentials' -import { GetEnvironmentVariablesClientTool } from '@/lib/copilot/tools/client/user/get-environment-variables' -import { GetOAuthCredentialsClientTool } from '@/lib/copilot/tools/client/user/get-oauth-credentials' -import { SetEnvironmentVariablesClientTool } from '@/lib/copilot/tools/client/user/set-environment-variables' +import { SERVER_TOOL_METADATA } from '@/lib/copilot/tools/client/server-tool-metadata' import { CheckDeploymentStatusClientTool } from '@/lib/copilot/tools/client/workflow/check-deployment-status' import { CreateWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/create-workflow' import { DeployWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/deploy-workflow' @@ -62,7 +49,6 @@ import { GetBlockOutputsClientTool } from '@/lib/copilot/tools/client/workflow/g import { GetBlockUpstreamReferencesClientTool } from '@/lib/copilot/tools/client/workflow/get-block-upstream-references' import { GetGlobalWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/get-global-workflow-variables' import { GetUserWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/get-user-workflow' -import { GetWorkflowConsoleClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-console' import { GetWorkflowFromNameClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-from-name' import { ListUserWorkflowsClientTool } from '@/lib/copilot/tools/client/workflow/list-user-workflows' import { RenameWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/rename-workflow' @@ -94,10 +80,10 @@ function clientTool(Ctor: ClientToolCtor): CopilotToolDefinition { } } -function serverTool(Ctor: Pick<ClientToolCtor, 'metadata'>): CopilotToolDefinition { +function serverTool(toolName: keyof typeof SERVER_TOOL_METADATA): CopilotToolDefinition { return { execution: 'server', - metadata: Ctor.metadata, + metadata: SERVER_TOOL_METADATA[toolName], } } @@ -111,17 +97,17 @@ function cloneArgs(args: Record<string, any> | undefined): Record<string, any> { const COPILOT_TOOL_REGISTRY: Record<ToolId, CopilotToolDefinition> = { run_workflow: clientTool(RunWorkflowClientTool), - get_workflow_console: serverTool(GetWorkflowConsoleClientTool), - get_blocks_and_tools: serverTool(GetBlocksAndToolsClientTool), - get_blocks_metadata: serverTool(GetBlocksMetadataClientTool), - get_indicator_catalog: serverTool(GetIndicatorCatalogClientTool), - get_indicator_metadata: serverTool(GetIndicatorMetadataClientTool), - get_trigger_blocks: serverTool(GetTriggerBlocksClientTool), - search_online: serverTool(SearchOnlineClientTool), - search_documentation: serverTool(SearchDocumentationClientTool), - get_environment_variables: serverTool(GetEnvironmentVariablesClientTool), - set_environment_variables: serverTool(SetEnvironmentVariablesClientTool), - get_credentials: serverTool(GetCredentialsClientTool), + get_workflow_console: serverTool('get_workflow_console'), + get_blocks_and_tools: serverTool('get_blocks_and_tools'), + get_blocks_metadata: serverTool('get_blocks_metadata'), + get_indicator_catalog: serverTool('get_indicator_catalog'), + get_indicator_metadata: serverTool('get_indicator_metadata'), + get_trigger_blocks: serverTool('get_trigger_blocks'), + search_online: serverTool('search_online'), + search_documentation: serverTool('search_documentation'), + get_environment_variables: serverTool('get_environment_variables'), + set_environment_variables: serverTool('set_environment_variables'), + get_credentials: serverTool('get_credentials'), knowledge_base: clientTool(KnowledgeBaseClientTool), list_custom_tools: clientTool(ListCustomToolsClientTool), get_custom_tool: clientTool(GetCustomToolClientTool), @@ -146,10 +132,10 @@ const COPILOT_TOOL_REGISTRY: Record<ToolId, CopilotToolDefinition> = { create_mcp_server: clientTool(CreateMcpServerClientTool), edit_mcp_server: clientTool(EditMcpServerClientTool), rename_mcp_server: clientTool(RenameMcpServerClientTool), - list_gdrive_files: serverTool(ListGDriveFilesClientTool), - read_gdrive_file: serverTool(ReadGDriveFileClientTool), - get_oauth_credentials: serverTool(GetOAuthCredentialsClientTool), - make_api_request: serverTool(MakeApiRequestClientTool), + list_gdrive_files: serverTool('list_gdrive_files'), + read_gdrive_file: serverTool('read_gdrive_file'), + get_oauth_credentials: serverTool('get_oauth_credentials'), + make_api_request: serverTool('make_api_request'), plan: clientTool(PlanClientTool), checkoff_todo: clientTool(CheckoffTodoClientTool), mark_todo_in_progress: clientTool(MarkTodoInProgressClientTool), diff --git a/apps/tradinggoose/stores/copilot/types.ts b/apps/tradinggoose/stores/copilot/types.ts index 10b130b47..2f85f9bb4 100644 --- a/apps/tradinggoose/stores/copilot/types.ts +++ b/apps/tradinggoose/stores/copilot/types.ts @@ -195,10 +195,6 @@ export interface CopilotActions { } ) => Promise<void> abortMessage: () => void - updatePreviewToolCallState: ( - toolCallState: 'accepted' | 'rejected' | 'error', - toolCallId?: string - ) => void setToolCallState: (toolCall: any, newState: ClientToolCallState, options?: any) => void saveChatMessages: ( chatId: string, diff --git a/apps/tradinggoose/stores/dashboard/pair-store.test.ts b/apps/tradinggoose/stores/dashboard/pair-store.test.ts index 0a561b36f..415bb2777 100644 --- a/apps/tradinggoose/stores/dashboard/pair-store.test.ts +++ b/apps/tradinggoose/stores/dashboard/pair-store.test.ts @@ -1,10 +1,13 @@ import { beforeEach, describe, expect, it } from 'vitest' import { type PairColorContext, usePairColorStore } from '@/stores/dashboard/pair-store' -import { type PairColor, PAIR_COLORS } from '@/widgets/pair-colors' +import { PAIR_COLORS, type PairColor } from '@/widgets/pair-colors' function resetPairContexts() { usePairColorStore.setState({ - contexts: Object.fromEntries(PAIR_COLORS.map((color) => [color, {}])) as Record<PairColor, PairColorContext>, + contexts: Object.fromEntries(PAIR_COLORS.map((color) => [color, {}])) as Record< + PairColor, + PairColorContext + >, }) } @@ -13,211 +16,128 @@ describe('pair-store linked context', () => { resetPairContexts() }) - it('ignores unsupported legacy keys instead of migrating them', () => { + it('ignores unsupported shared keys instead of persisting them', () => { const { setContext } = usePairColorStore.getState() setContext('blue', { workflowId: 'workflow-a', channelId: 'pair-blue', + reviewTarget: { + reviewSessionId: 'review-a', + }, copilotChatId: 'legacy-review-session', - } as PairColorContext & { copilotChatId?: string }) + } as PairColorContext & { + channelId?: string + reviewTarget?: { reviewSessionId?: string | null } + copilotChatId?: string + }) const context = usePairColorStore.getState().contexts.blue as PairColorContext & { + channelId?: string + reviewTarget?: unknown copilotChatId?: string } - expect(context).toMatchObject({ + expect(context).toEqual({ workflowId: 'workflow-a', - channelId: 'pair-blue', }) + expect(context.channelId).toBeUndefined() expect(context.reviewTarget).toBeUndefined() expect(context.copilotChatId).toBeUndefined() }) - it('preserves explicit review target when the ambient workflow changes without a replacement target', () => { + it('stores only canonical listing identity fields in linked color context', () => { const { setContext } = usePairColorStore.getState() setContext('blue', { - workflowId: 'workflow-a', - reviewTarget: { - reviewSessionId: 'review-a', - reviewEntityKind: 'workflow', - reviewEntityId: 'workflow-a', - reviewDraftSessionId: null, + listing: { + listing_id: 'AAPL', + base_id: 'ignored-base', + quote_id: 'ignored-quote', + listing_type: 'default', + provider: 'alpaca', + marketProvider: 'polygon', + accountId: 'acct-1', + providerParams: { apiKey: 'secret' }, + } as PairColorContext['listing'] & { + provider: string + marketProvider: string + accountId: string + providerParams: Record<string, unknown> }, - skillId: 'skill-a', - customToolId: 'tool-a', - mcpServerId: 'mcp-a', - indicatorId: 'indicator-a', - channelId: 'pair-blue', }) - setContext('blue', { - workflowId: 'workflow-b', - channelId: 'pair-blue', - }) + const listing = usePairColorStore.getState().contexts.blue.listing - expect(usePairColorStore.getState().contexts.blue).toMatchObject({ - workflowId: 'workflow-b', - skillId: 'skill-a', - customToolId: 'tool-a', - mcpServerId: 'mcp-a', - indicatorId: 'indicator-a', - channelId: 'pair-blue', - reviewTarget: { - reviewSessionId: 'review-a', - reviewEntityKind: 'workflow', - reviewEntityId: 'workflow-a', - reviewDraftSessionId: null, - }, + expect(listing).toEqual({ + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', }) + expect(listing).not.toHaveProperty('provider') + expect(listing).not.toHaveProperty('marketProvider') + expect(listing).not.toHaveProperty('accountId') + expect(listing).not.toHaveProperty('providerParams') }) - it('preserves explicitly supplied replacement targets on an entity change', () => { + it('preserves allowed shared ids when workflow selection changes', () => { const { setContext } = usePairColorStore.getState() setContext('blue', { workflowId: 'workflow-a', - reviewTarget: { - reviewSessionId: 'review-a', - reviewEntityKind: 'workflow', - reviewEntityId: 'workflow-a', - reviewDraftSessionId: null, - }, + skillId: 'skill-a', + customToolId: 'tool-a', + mcpServerId: 'mcp-a', + indicatorId: 'indicator-a', }) setContext('blue', { workflowId: 'workflow-b', - reviewTarget: { - reviewSessionId: 'review-b', - reviewEntityKind: 'workflow', - reviewEntityId: 'workflow-b', - reviewDraftSessionId: null, - }, }) - expect(usePairColorStore.getState().contexts.blue).toMatchObject({ + expect(usePairColorStore.getState().contexts.blue).toEqual({ workflowId: 'workflow-b', - reviewTarget: { - reviewSessionId: 'review-b', - reviewEntityKind: 'workflow', - reviewEntityId: 'workflow-b', - reviewDraftSessionId: null, - }, - }) - }) - - it('preserves explicitly supplied review targets that match the existing entity id', () => { - const { setContext } = usePairColorStore.getState() - - setContext('blue', { - skillId: 'skill-a', - }) - - setContext('blue', { - reviewTarget: { - reviewSessionId: 'review-skill-a', - reviewEntityKind: 'skill', - reviewEntityId: 'skill-a', - reviewDraftSessionId: null, - }, - }) - - expect(usePairColorStore.getState().contexts.blue).toMatchObject({ skillId: 'skill-a', - reviewTarget: { - reviewSessionId: 'review-skill-a', - reviewEntityKind: 'skill', - reviewEntityId: 'skill-a', - reviewDraftSessionId: null, - }, + customToolId: 'tool-a', + mcpServerId: 'mcp-a', + indicatorId: 'indicator-a', }) }) - it('keeps review state separate from other entity kinds', () => { - const { setContext } = usePairColorStore.getState() - - setContext('blue', { - reviewTarget: { - reviewSessionId: 'review-skill-a', - reviewEntityKind: 'skill', - reviewEntityId: 'skill-a', - reviewDraftSessionId: null, + it('strips stale unsupported keys that were already present before the next write', () => { + usePairColorStore.setState((state) => ({ + contexts: { + ...state.contexts, + blue: { + workflowId: 'workflow-a', + skillId: 'skill-a', + reviewTarget: { + reviewSessionId: 'review-a', + }, + channelId: 'pair-blue', + } as PairColorContext & { + reviewTarget?: { reviewSessionId?: string | null } + channelId?: string + }, }, - skillId: 'skill-a', - customToolId: 'tool-a', - }) - - setContext('blue', { - indicatorId: 'indicator-b', - }) + })) - expect(usePairColorStore.getState().contexts.blue).toMatchObject({ - reviewTarget: { - reviewSessionId: 'review-skill-a', - reviewEntityKind: 'skill', - reviewEntityId: 'skill-a', - reviewDraftSessionId: null, - }, - skillId: 'skill-a', - customToolId: 'tool-a', + usePairColorStore.getState().setContext('blue', { indicatorId: 'indicator-b', }) - }) - it('preserves explicit review target when the same ambient entity kind changes', () => { - const { setContext } = usePairColorStore.getState() + const context = usePairColorStore.getState().contexts.blue as PairColorContext & { + reviewTarget?: unknown + channelId?: string + } - setContext('blue', { - reviewTarget: { - reviewSessionId: 'review-skill-a', - reviewEntityKind: 'skill', - reviewEntityId: 'skill-a', - reviewDraftSessionId: null, - }, + expect(context).toEqual({ + workflowId: 'workflow-a', skillId: 'skill-a', + indicatorId: 'indicator-b', }) - - setContext('blue', { - skillId: 'skill-b', - }) - - expect(usePairColorStore.getState().contexts.blue).toMatchObject({ - skillId: 'skill-b', - reviewTarget: { - reviewSessionId: 'review-skill-a', - reviewEntityKind: 'skill', - reviewEntityId: 'skill-a', - reviewDraftSessionId: null, - }, - }) - }) - - it('preserves explicit draft review target when an ambient saved entity id is selected', () => { - const { setContext } = usePairColorStore.getState() - - setContext('blue', { - reviewTarget: { - reviewSessionId: 'review-draft-skill', - reviewEntityKind: 'skill', - reviewEntityId: null, - reviewDraftSessionId: 'draft-skill', - }, - skillId: null, - }) - - setContext('blue', { - skillId: 'skill-saved', - }) - - expect(usePairColorStore.getState().contexts.blue).toMatchObject({ - skillId: 'skill-saved', - reviewTarget: { - reviewSessionId: 'review-draft-skill', - reviewEntityKind: 'skill', - reviewEntityId: null, - reviewDraftSessionId: 'draft-skill', - }, - }) + expect(context.reviewTarget).toBeUndefined() + expect(context.channelId).toBeUndefined() }) }) diff --git a/apps/tradinggoose/stores/dashboard/pair-store.ts b/apps/tradinggoose/stores/dashboard/pair-store.ts index 7cbea8c64..b02ca6c99 100644 --- a/apps/tradinggoose/stores/dashboard/pair-store.ts +++ b/apps/tradinggoose/stores/dashboard/pair-store.ts @@ -1,37 +1,23 @@ import { createWithEqualityFn as create } from 'zustand/traditional' -import type { ListingIdentity } from '@/lib/listing/identity' +import { + type ListingIdentity, + type ListingInputValue, + toListingValueObject, +} from '@/lib/listing/identity' +import { normalizeOptionalString } from '@/lib/utils' import type { PairColor } from '@/widgets/pair-colors' import { PAIR_COLORS } from '@/widgets/pair-colors' -export type PairReviewTarget = { - reviewSessionId?: string | null - reviewEntityKind?: string | null - reviewEntityId?: string | null - reviewDraftSessionId?: string | null -} - export type PairColorContext = { workflowId?: string listing?: ListingIdentity | null - updatedAt?: number - channelId?: string - reviewTarget?: PairReviewTarget | null indicatorId?: string | null mcpServerId?: string | null customToolId?: string | null skillId?: string | null } -const ENTITY_CONTEXT_KEYS = ['indicatorId', 'mcpServerId', 'customToolId', 'skillId'] as const -const REVIEW_ENTITY_KINDS = ['workflow', 'indicator', 'mcp_server', 'custom_tool', 'skill'] - -const PAIR_CONTEXT_KEYS = [ - 'workflowId', - 'listing', - 'channelId', - 'reviewTarget', - ...ENTITY_CONTEXT_KEYS, -] as const +type PairColorContextSource = PairColorContext | Record<string, unknown> | null | undefined interface PairStoreState { contexts: Record<PairColor, PairColorContext> @@ -47,79 +33,61 @@ const emptyContexts = PAIR_COLORS.reduce<Record<PairColor, PairColorContext>>( {} as Record<PairColor, PairColorContext> ) -function sanitizePairColorContext(ctx: PairColorContext): PairColorContext { - return Object.fromEntries( - Object.entries(ctx).filter(([key]) => - (PAIR_CONTEXT_KEYS as readonly string[]).includes(key) - ) - ) as PairColorContext -} +function sanitizePairColorContext(ctx: PairColorContextSource): PairColorContext { + if (!ctx || typeof ctx !== 'object' || Array.isArray(ctx)) { + return {} + } -function normalizeContextId(value: unknown): string | null { - return typeof value === 'string' && value.trim() ? value.trim() : null -} + const next: PairColorContext = {} + const workflowId = normalizeOptionalString((ctx as { workflowId?: unknown }).workflowId) + const listing = toListingValueObject( + (ctx as { listing?: unknown }).listing as ListingInputValue | null | undefined + ) + const indicatorId = normalizeOptionalString((ctx as { indicatorId?: unknown }).indicatorId) + const mcpServerId = normalizeOptionalString((ctx as { mcpServerId?: unknown }).mcpServerId) + const customToolId = normalizeOptionalString((ctx as { customToolId?: unknown }).customToolId) + const skillId = normalizeOptionalString((ctx as { skillId?: unknown }).skillId) + + if (workflowId) { + next.workflowId = workflowId + } -function omitReviewTarget(context: PairColorContext): PairColorContext { - const { reviewTarget: _removed, ...rest } = context - return rest -} + if (listing) { + next.listing = listing + } -function sanitizeReviewTarget(context: PairColorContext): PairColorContext { - const reviewTargetKind = normalizeContextId(context.reviewTarget?.reviewEntityKind) - const reviewEntityId = normalizeContextId(context.reviewTarget?.reviewEntityId) - const reviewDraftSessionId = normalizeContextId(context.reviewTarget?.reviewDraftSessionId) - const reviewSessionId = normalizeContextId(context.reviewTarget?.reviewSessionId) + if (indicatorId) { + next.indicatorId = indicatorId + } - if (!context.reviewTarget) { - return context + if (mcpServerId) { + next.mcpServerId = mcpServerId } - if ( - !reviewTargetKind || - !REVIEW_ENTITY_KINDS.includes(reviewTargetKind) || - (!reviewEntityId && !reviewDraftSessionId && !reviewSessionId) - ) { - return omitReviewTarget(context) + if (customToolId) { + next.customToolId = customToolId } - return { - ...context, - reviewTarget: { - reviewEntityKind: reviewTargetKind, - reviewEntityId, - reviewDraftSessionId, - reviewSessionId, - }, + if (skillId) { + next.skillId = skillId } + + return next } -export function normalizePairColorContext(ctx: PairColorContext): PairColorContext { - return sanitizeReviewTarget(sanitizePairColorContext(ctx)) +export function normalizePairColorContext(ctx: PairColorContextSource): PairColorContext { + return sanitizePairColorContext(ctx) } export const usePairColorStore = create<PairStoreState>((set) => ({ contexts: emptyContexts, setContext: (color, ctx) => set((state) => { - const nextContext = sanitizePairColorContext(ctx) - const previous = state.contexts[color] - const reviewTargetChanged = - Object.hasOwn(nextContext, 'reviewTarget') && - nextContext.reviewTarget !== previous.reviewTarget - - let next: PairColorContext = { + const previous = sanitizePairColorContext(state.contexts[color] ?? {}) + const next = sanitizePairColorContext({ ...previous, - ...nextContext, - updatedAt: Date.now(), - } - - if (reviewTargetChanged && nextContext.reviewTarget == null) { - next = omitReviewTarget(next) - } else if (reviewTargetChanged) { - next.reviewTarget = nextContext.reviewTarget - } - - next = sanitizeReviewTarget(next) + ...ctx, + }) return { contexts: { diff --git a/apps/tradinggoose/stores/organization/store.ts b/apps/tradinggoose/stores/organization/store.ts index 986762dad..89a1a90e4 100644 --- a/apps/tradinggoose/stores/organization/store.ts +++ b/apps/tradinggoose/stores/organization/store.ts @@ -2,6 +2,7 @@ import { createWithEqualityFn as create } from 'zustand/traditional' import { devtools } from 'zustand/middleware' import { client } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' +import { buildLocaleRequestHeaders, defaultLocale, stripLocaleFromPathname } from '@/i18n/utils' import type { OrganizationStore, WorkspaceInvitation } from '@/stores/organization/types' import { calculateSeatUsage, @@ -271,8 +272,15 @@ export const useOrganizationStore = create<OrganizationStore>()( loadUserWorkspaces: async (userId?: string) => { try { + const locale = + typeof window !== 'undefined' + ? stripLocaleFromPathname(window.location.pathname).locale + : defaultLocale + // Get all workspaces the user is a member of - const workspacesResponse = await fetch('/api/workspaces') + const workspacesResponse = await fetch('/api/workspaces', { + headers: buildLocaleRequestHeaders(locale), + }) if (!workspacesResponse.ok) { logger.error('Failed to fetch workspaces') return diff --git a/apps/tradinggoose/stores/workflows/registry/store.ts b/apps/tradinggoose/stores/workflows/registry/store.ts index e40d1ffe2..dbed332de 100644 --- a/apps/tradinggoose/stores/workflows/registry/store.ts +++ b/apps/tradinggoose/stores/workflows/registry/store.ts @@ -106,12 +106,8 @@ const getPairColorFromChannelId = (channelId?: string): PairColor | null => { const syncPairContextForChannel = (channelId: string | undefined, workflowId: string | null) => { const pairColor = getPairColorFromChannelId(channelId) if (!pairColor) return - const { contexts, setContext } = usePairColorStore.getState() - const current = contexts[pairColor] - setContext(pairColor, { - ...current, - workflowId: workflowId ?? undefined, - }) + const { setContext } = usePairColorStore.getState() + setContext(pairColor, { workflowId: workflowId ?? undefined }) } const syncRegistryFromPairContexts = ( diff --git a/apps/tradinggoose/tools/index.ts b/apps/tradinggoose/tools/index.ts index 974ea5bcb..ba0434fd2 100644 --- a/apps/tradinggoose/tools/index.ts +++ b/apps/tradinggoose/tools/index.ts @@ -304,19 +304,6 @@ export async function executeTool( throw new Error(`Tool not found: ${toolId}`) } - // If we have a credential parameter, fetch the access token - // Agents may pass provider-specific credential params (e.g., alpacaCredential); normalize first - if (!contextParams.credential) { - contextParams.credential = - contextParams.alpacaCredential || - contextParams.tradierCredential || - contextParams.credential - - // Avoid leaking provider-specific credential params downstream - contextParams.alpacaCredential = undefined - contextParams.tradierCredential = undefined - } - if (contextParams.credential) { logger.info( `[${requestId}] Tool ${toolId} needs access token for credential: ${contextParams.credential}` @@ -371,6 +358,9 @@ export async function executeTool( const data = await response.json() contextParams.accessToken = data.accessToken + if (data.providerId) { + contextParams.credentialServiceId = data.providerId + } if (data.apiKey) { contextParams.apiKey = data.apiKey } diff --git a/apps/tradinggoose/tools/trading/action.test.ts b/apps/tradinggoose/tools/trading/action.test.ts index 3e78f91b5..3da280a2b 100644 --- a/apps/tradinggoose/tools/trading/action.test.ts +++ b/apps/tradinggoose/tools/trading/action.test.ts @@ -12,7 +12,6 @@ const baseParams = { side: 'buy' as const, orderType: 'market' as const, timeInForce: 'day' as const, - environment: 'paper' as const, accessToken: 'test-token', } diff --git a/apps/tradinggoose/tools/trading/action.ts b/apps/tradinggoose/tools/trading/action.ts index 560d118c5..06df14d7c 100644 --- a/apps/tradinggoose/tools/trading/action.ts +++ b/apps/tradinggoose/tools/trading/action.ts @@ -1,7 +1,12 @@ import { toListingValueObject } from '@/lib/listing/identity' import { createLogger } from '@/lib/logs/console/logger' import { getBaseUrl } from '@/lib/urls/utils' -import { executeTradingProviderRequest, getTradingProvider } from '@/providers/trading' +import { + executeTradingProviderRequest, + getTradingProvider, + getTradingProviderOAuthEnvironment, + getTradingProviderParamDefinitions, +} from '@/providers/trading' import type { OrderSubmit, OrderSubmitRequest, @@ -13,6 +18,20 @@ import type { ToolConfig } from '@/tools/types' const logger = createLogger('TradingActionTool') +const resolveProviderEnvironment = (params: TradingActionParams) => { + const credentialEnvironment = getTradingProviderOAuthEnvironment( + params.provider, + params.credentialServiceId + ) + if (credentialEnvironment) return credentialEnvironment + + return getTradingProviderParamDefinitions(params.provider, 'order').some( + (definition) => definition.id === 'environment' + ) + ? params.environment + : undefined +} + const ORDER_HISTORY_OMIT_KEYS = new Set([ 'provider', 'environment', @@ -29,11 +48,8 @@ const ORDER_HISTORY_OMIT_KEYS = new Set([ 'orderSizingMode', 'orderClass', 'credential', + 'credentialServiceId', 'accessToken', - 'apiKey', - 'apiSecret', - 'tradierCredential', - 'alpacaCredential', '_context', '_workflowId', '_credentialId', @@ -192,7 +208,11 @@ const buildOrderRequest = (params: TradingActionParams) => { validateOrderSizing(normalized) const provider = getTradingProvider(normalized.provider) const { provider: providerId, ...rest } = normalized - const request = executeTradingProviderRequest(providerId, { kind: 'order', ...rest }) + const request = executeTradingProviderRequest(providerId, { + kind: 'order', + ...rest, + environment: resolveProviderEnvironment(normalized), + }) logger.info(`Building order request for ${provider.id}`, { orderType: normalized.orderType || provider.defaults?.orderType || 'market', timeInForce: normalized.timeInForce || provider.defaults?.timeInForce, @@ -300,7 +320,7 @@ export const tradingActionTool: ToolConfig<TradingActionParams, TradingActionRes type: 'string', required: false, visibility: 'user-only', - description: 'Trading environment for Alpaca (paper or live).', + description: 'Trading environment for providers that expose one.', }, credential: { type: 'string', @@ -308,36 +328,12 @@ export const tradingActionTool: ToolConfig<TradingActionParams, TradingActionRes visibility: 'hidden', description: 'OAuth credential id for the selected broker (populated from selected account).', }, - tradierCredential: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Tradier OAuth credential id.', - }, - alpacaCredential: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Alpaca OAuth credential id.', - }, accessToken: { type: 'string', required: false, visibility: 'hidden', description: 'OAuth access token (injected from credential).', }, - apiKey: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Alpaca API key ID (optional if using OAuth).', - }, - apiSecret: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Alpaca API secret key (optional if using OAuth).', - }, accountId: { type: 'string', required: false, @@ -385,7 +381,7 @@ export const tradingActionTool: ToolConfig<TradingActionParams, TradingActionRes const orderSubmit: OrderSubmit = { provider: params.provider, - environment: params.environment, + environment: resolveProviderEnvironment(params), recordedAt: new Date().toISOString(), workflowId: context?.workflowId ?? (params as any)._workflowId, workflowExecutionId: context?.executionId, diff --git a/apps/tradinggoose/tools/trading/holdings.ts b/apps/tradinggoose/tools/trading/holdings.ts index b917571b7..98c4c43c3 100644 --- a/apps/tradinggoose/tools/trading/holdings.ts +++ b/apps/tradinggoose/tools/trading/holdings.ts @@ -1,14 +1,37 @@ import { createLogger } from '@/lib/logs/console/logger' -import { executeTradingProviderRequest, getTradingProvider } from '@/providers/trading' -import type { ToolConfig } from '@/tools/types' +import { + executeTradingProviderRequest, + getTradingProvider, + getTradingProviderOAuthEnvironment, + getTradingProviderParamDefinitions, +} from '@/providers/trading' import type { TradingHoldingsParams, TradingHoldingsResponse } from '@/tools/trading/types' +import type { ToolConfig } from '@/tools/types' const logger = createLogger('TradingHoldingsTool') +const resolveProviderEnvironment = (params: TradingHoldingsParams) => { + const credentialEnvironment = getTradingProviderOAuthEnvironment( + params.provider, + params.credentialServiceId + ) + if (credentialEnvironment) return credentialEnvironment + + return getTradingProviderParamDefinitions(params.provider, 'holdings').some( + (definition) => definition.id === 'environment' + ) + ? params.environment + : undefined +} + const buildHoldingsRequest = (params: TradingHoldingsParams) => { const provider = getTradingProvider(params.provider) const { provider: providerId, ...rest } = params - const request = executeTradingProviderRequest(providerId, { kind: 'holdings', ...rest }) + const request = executeTradingProviderRequest(providerId, { + kind: 'holdings', + ...rest, + environment: resolveProviderEnvironment(params), + }) logger.info(`Building holdings request for ${provider.id}`) return request } @@ -39,7 +62,7 @@ export const tradingHoldingsTool: ToolConfig<TradingHoldingsParams, TradingHoldi type: 'string', required: false, visibility: 'user-only', - description: 'Trading environment for Alpaca (paper or live).', + description: 'Trading environment for providers that expose one.', }, credential: { type: 'string', @@ -47,18 +70,6 @@ export const tradingHoldingsTool: ToolConfig<TradingHoldingsParams, TradingHoldi visibility: 'hidden', description: 'OAuth credential id for the selected broker (populated from selected account).', }, - tradierCredential: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Tradier OAuth credential id.', - }, - alpacaCredential: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Alpaca OAuth credential id.', - }, accessToken: { type: 'string', required: false, @@ -88,10 +99,8 @@ export const tradingHoldingsTool: ToolConfig<TradingHoldingsParams, TradingHoldi const raw = await response.json().catch(() => ({})) const normalized = provider.normalizeHoldings ? provider.normalizeHoldings(raw, { - environment: params.environment, + environment: resolveProviderEnvironment(params), accessToken: params.accessToken, - apiKey: params.apiKey, - apiSecret: params.apiSecret, accountId: params.accountId, providerId: provider.id, providerName: provider.name, diff --git a/apps/tradinggoose/tools/trading/order_detail.ts b/apps/tradinggoose/tools/trading/order_detail.ts index 669df344c..07931ff0b 100644 --- a/apps/tradinggoose/tools/trading/order_detail.ts +++ b/apps/tradinggoose/tools/trading/order_detail.ts @@ -1,6 +1,15 @@ +import { getTradingProviderOAuthEnvironment } from '@/providers/trading' import type { TradingOrderDetailParams, TradingOrderDetailResponse } from '@/tools/trading/types' import type { ToolConfig } from '@/tools/types' +const resolveProviderEnvironment = (params: TradingOrderDetailParams) => { + if (!params.provider) return params.environment + return ( + getTradingProviderOAuthEnvironment(params.provider, params.credentialServiceId) ?? + params.environment + ) +} + export const tradingOrderDetailTool: ToolConfig< TradingOrderDetailParams, TradingOrderDetailResponse @@ -30,7 +39,7 @@ export const tradingOrderDetailTool: ToolConfig< type: 'string', required: false, visibility: 'user-only', - description: 'Optional environment override (paper or live) for provider order-detail fetch.', + description: 'Trading environment for providers that expose one.', }, credential: { type: 'string', @@ -38,36 +47,12 @@ export const tradingOrderDetailTool: ToolConfig< visibility: 'hidden', description: 'OAuth credential id for the selected broker (populated from selected account).', }, - tradierCredential: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Tradier OAuth credential id.', - }, - alpacaCredential: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Alpaca OAuth credential id.', - }, accessToken: { type: 'string', required: false, visibility: 'hidden', description: 'OAuth access token (injected from credential).', }, - apiKey: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Alpaca API key ID (optional if using OAuth).', - }, - apiSecret: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Alpaca API secret key (optional if using OAuth).', - }, accountId: { type: 'string', required: false, @@ -85,10 +70,8 @@ export const tradingOrderDetailTool: ToolConfig< body: (params) => ({ orderId: params.orderId, provider: params.provider, - environment: params.environment, + environment: resolveProviderEnvironment(params), accessToken: params.accessToken, - apiKey: params.apiKey, - apiSecret: params.apiSecret, accountId: params.accountId, }), }, diff --git a/apps/tradinggoose/tools/trading/types.ts b/apps/tradinggoose/tools/trading/types.ts index 4e2831039..3fb23b6a5 100644 --- a/apps/tradinggoose/tools/trading/types.ts +++ b/apps/tradinggoose/tools/trading/types.ts @@ -21,11 +21,8 @@ export interface TradingActionParams { environment?: 'paper' | 'live' // Auth credential?: string + credentialServiceId?: string accessToken?: string - apiKey?: string - apiSecret?: string - tradierCredential?: string - alpacaCredential?: string // Provider-specific extras accountId?: string orderSizingMode?: string @@ -36,8 +33,7 @@ export interface TradingHoldingsParams { provider: TradingProviderId environment?: 'paper' | 'live' accessToken?: string - apiKey?: string - apiSecret?: string + credentialServiceId?: string accountId?: string } @@ -46,11 +42,8 @@ export interface TradingOrderDetailParams { provider?: TradingProviderId environment?: 'paper' | 'live' credential?: string + credentialServiceId?: string accessToken?: string - apiKey?: string - apiSecret?: string - tradierCredential?: string - alpacaCredential?: string accountId?: string } diff --git a/apps/tradinggoose/vitest.config.mts b/apps/tradinggoose/vitest.config.mts index 4bf368e44..258f83dd5 100644 --- a/apps/tradinggoose/vitest.config.mts +++ b/apps/tradinggoose/vitest.config.mts @@ -12,7 +12,7 @@ const configDir = dirname(fileURLToPath(import.meta.url)) loadEnvConfig(projectDir) export default defineConfig({ - plugins: [react(), tsconfigPaths()], + plugins: [react(), tsconfigPaths()] as never, test: { globals: true, environment: 'node', diff --git a/apps/tradinggoose/widgets/events.ts b/apps/tradinggoose/widgets/events.ts index 7a05da242..555e514dc 100644 --- a/apps/tradinggoose/widgets/events.ts +++ b/apps/tradinggoose/widgets/events.ts @@ -52,6 +52,10 @@ export const SKILL_EDITOR_STATE_EVENT = 'skill-editor:state' export const MCP_WIDGET_SELECT_SERVER_EVENT = 'mcp-widgets:select-server' export const MCP_EDITOR_ACTION_EVENT = 'mcp-editor:action' export const WATCHLIST_WIDGET_UPDATE_PARAMS_EVENT = 'watchlist-widgets:update-params' +export const PORTFOLIO_SNAPSHOT_WIDGET_UPDATE_PARAMS_EVENT = + 'portfolio-snapshot-widgets:update-params' +export const QUICK_ORDER_WIDGET_UPDATE_PARAMS_EVENT = 'quick-order-widgets:update-params' +export const HEATMAP_WIDGET_UPDATE_PARAMS_EVENT = 'heatmap-widgets:update-params' export type WorkflowWidgetSelectEventDetail = { workflowId: string @@ -71,6 +75,24 @@ export type WatchlistWidgetUpdateEventDetail = { widgetKey?: string } +export type PortfolioSnapshotWidgetUpdateEventDetail = { + params: Record<string, unknown> + panelId?: string + widgetKey?: string +} + +export type QuickOrderWidgetUpdateEventDetail = { + params: Record<string, unknown> + panelId?: string + widgetKey?: string +} + +export type HeatmapWidgetUpdateEventDetail = { + params: Record<string, unknown> + panelId?: string + widgetKey?: string +} + export type IndicatorWidgetSelectEventDetail = { indicatorId?: string | null panelId?: string diff --git a/apps/tradinggoose/widgets/hooks/use-workflow-widget-state.ts b/apps/tradinggoose/widgets/hooks/use-workflow-widget-state.ts index 40fd68547..b627761ed 100644 --- a/apps/tradinggoose/widgets/hooks/use-workflow-widget-state.ts +++ b/apps/tradinggoose/widgets/hooks/use-workflow-widget-state.ts @@ -1,15 +1,17 @@ 'use client' import { useEffect, useMemo, useState } from 'react' +import { useLocale } from 'next-intl' import { shallow } from 'zustand/shallow' import { type PairColorContext, usePairColorStore, - useSetPairColorContext, } from '@/stores/dashboard/pair-store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { WORKSPACE_BOOTSTRAP_CHANNEL } from '@/stores/workflows/registry/types' import { resolveWidgetChannel } from '@/widgets/hooks/use-widget-channel' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import type { PairColor } from '@/widgets/pair-colors' import type { WidgetComponentProps } from '@/widgets/types' @@ -36,7 +38,6 @@ type UseWorkflowWidgetStateResult = { activeWorkflowIdForChannel: string | null } -const DEFAULT_LOAD_ERROR_MESSAGE = 'Unable to load workflows' const MAX_METADATA_LOAD_ATTEMPTS = 2 const EMPTY_PAIR_CONTEXT: Readonly<PairColorContext> = Object.freeze({}) @@ -52,6 +53,8 @@ export const useWorkflowWidgetState = ({ activateWorkflow = true, usePairWorkflowContext = true, }: UseWorkflowWidgetStateOptions): UseWorkflowWidgetStateResult => { + const locale = useLocale() as LocaleCode + const widgetsCopy = getPublicCopy(locale).workspace.widgets const { resolvedPairColor, channelId } = resolveWidgetChannel({ pairColor, widget, @@ -65,7 +68,6 @@ export const useWorkflowWidgetState = ({ const pairContext = usePairColorStore((state) => shouldUsePairWorkflowContext ? state.contexts[resolvedPairColor] : EMPTY_PAIR_CONTEXT ) - const setPairContext = useSetPairColorContext() const { workflows, loadWorkflows, setActiveWorkflow } = useWorkflowRegistry( (state) => ({ workflows: state.workflows, @@ -159,10 +161,7 @@ export const useWorkflowWidgetState = ({ console.error(`Failed to load workflows for ${loggerScope}`, error) setLoadError( - error instanceof Error && - (error.message === 'Unauthorized' || error.message === 'Forbidden') - ? 'Authentication required to load workflows' - : DEFAULT_LOAD_ERROR_MESSAGE + widgetsCopy.workflowDropdown.failedToLoad ) }) @@ -177,6 +176,7 @@ export const useWorkflowWidgetState = ({ loadWorkflows, loggerScope, metadataChannelId, + widgetsCopy.workflowDropdown.failedToLoad, ]) const resolvedWorkflowId = useMemo(() => { @@ -195,6 +195,10 @@ export const useWorkflowWidgetState = ({ return pairWorkflowId } + if (shouldUsePairWorkflowContext) { + return null + } + const channelWorkflowId = rawActiveWorkflowIdForChannel && workspaceWorkflowMap[rawActiveWorkflowIdForChannel] ? rawActiveWorkflowIdForChannel @@ -253,30 +257,6 @@ export const useWorkflowWidgetState = ({ return hasRequestedLoad && metadataHydration.phase !== 'metadata-loading' }, [workspaceId, workspaceHasWorkflows, loadError, hasRequestedLoad, metadataHydration.phase]) - useEffect(() => { - if (!shouldUsePairWorkflowContext || !resolvedWorkflowId) { - return - } - - if (pairContext.workflowId === resolvedWorkflowId) { - return - } - - setPairContext(resolvedPairColor, { - ...pairContext, - workflowId: resolvedWorkflowId, - listing: pairContext.listing, - channelId, - }) - }, [ - shouldUsePairWorkflowContext, - resolvedWorkflowId, - pairContext.workflowId, - pairContext.listing, - setPairContext, - channelId, - ]) - useEffect(() => { if (resolvedPairColor !== 'gray' || !resolvedWorkflowId || !onWidgetParamsChange) { return diff --git a/apps/tradinggoose/widgets/layout.test.ts b/apps/tradinggoose/widgets/layout.test.ts index 5f289b245..48d6e18c9 100644 --- a/apps/tradinggoose/widgets/layout.test.ts +++ b/apps/tradinggoose/widgets/layout.test.ts @@ -59,13 +59,35 @@ describe('resolveWidgetParamsForPairColorChange', () => { ).toBe(params) }) - it('clears non chart params when switching to a linked color', () => { + it('preserves heatmap params when switching to a linked color', () => { + const params = { + sourceMode: 'portfolio', + marketProvider: 'polygon', + tradingProvider: 'alpaca', + credentialServiceId: 'cred-1', + accountId: 'acct-1', + marketProviderParams: { feed: 'sip' }, + } + expect( resolveWidgetParamsForPairColorChange( { - key: 'watchlist', + key: 'heatmap', pairColor: 'gray', - params: { provider: 'alpaca' }, + params, + }, + 'red' + ) + ).toBe(params) + }) + + it('clears pair-context-owned widget params when switching to a linked color', () => { + expect( + resolveWidgetParamsForPairColorChange( + { + key: 'editor_workflow', + pairColor: 'gray', + params: { workflowId: 'wf-local' }, }, 'red' ) @@ -116,7 +138,42 @@ describe('normalizeColorPairsState', () => { }) }) - it('reads nested reviewTarget format', () => { + it('keeps provider and account fields out of persisted color-pair listings', () => { + const normalized = normalizeColorPairsState({ + pairs: [ + { + color: 'red', + listing: { + listing_id: 'AAPL', + base_id: 'ignored-base', + quote_id: 'ignored-quote', + listing_type: 'default', + provider: 'alpaca', + marketProvider: 'polygon', + tradingProvider: 'alpaca', + accountId: 'acct-1', + providerParams: { apiKey: 'secret' }, + }, + }, + ], + }) + + const listing = normalized.pairs[0]?.listing + + expect(listing).toEqual({ + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }) + expect(listing).not.toHaveProperty('provider') + expect(listing).not.toHaveProperty('marketProvider') + expect(listing).not.toHaveProperty('tradingProvider') + expect(listing).not.toHaveProperty('accountId') + expect(listing).not.toHaveProperty('providerParams') + }) + + it('ignores nested reviewTarget format', () => { expect( normalizeColorPairsState({ pairs: [ @@ -137,12 +194,6 @@ describe('normalizeColorPairsState', () => { color: 'green', workflowId: 'wf-3', listing: null, - reviewTarget: { - reviewSessionId: 'review-2', - reviewEntityKind: 'indicator', - reviewEntityId: 'ind-1', - reviewDraftSessionId: undefined, - }, indicatorId: undefined, mcpServerId: undefined, customToolId: undefined, diff --git a/apps/tradinggoose/widgets/layout.ts b/apps/tradinggoose/widgets/layout.ts index eb6844ec5..d5e3fdb73 100644 --- a/apps/tradinggoose/widgets/layout.ts +++ b/apps/tradinggoose/widgets/layout.ts @@ -1,6 +1,5 @@ import { type ListingIdentity, toListingValueObject } from '@/lib/listing/identity' import { normalizeOptionalString } from '@/lib/utils' -import type { PairReviewTarget } from '@/stores/dashboard/pair-store' import type { PairColor } from '@/widgets/pair-colors' import { isPairColor } from '@/widgets/pair-colors' @@ -16,7 +15,6 @@ export type PersistedColorPair = { color: LinkedPairColor workflowId?: string | null listing?: ListingIdentity | null - reviewTarget?: PairReviewTarget indicatorId?: string | null mcpServerId?: string | null customToolId?: string | null @@ -86,9 +84,8 @@ export function resolveWidgetParamsForPairColorChange( return currentParams } - // Data Chart keeps provider and chart configuration widget-local while linked listings - // continue to resolve from the shared pair store. - if (widget?.key === 'data_chart') { + // Data-provider configuration stays widget-local even when listing selection is linked. + if (widget?.key === 'data_chart' || widget?.key === 'heatmap') { return currentParams } @@ -102,20 +99,6 @@ const normalizeListingIdentity = (value: unknown): ListingIdentity | null => { return listing } -const normalizeListingWithResolvedFields = (value: unknown): ListingIdentity | null => { - if (!value || typeof value !== 'object') return null - const identity = toListingValueObject(value as any) - if (!identity) return null - - return { - ...(value as Record<string, unknown>), - listing_id: identity.listing_id, - base_id: identity.base_id, - quote_id: identity.quote_id, - listing_type: identity.listing_type, - } as ListingIdentity -} - const normalizeListingParamsForStorage = ( params?: Record<string, unknown> | null ): Record<string, unknown> | null | undefined => { @@ -152,21 +135,7 @@ export function normalizeColorPairsState(state?: unknown): PersistedColorPairsSt } const workflowId = normalizeOptionalString((raw as { workflowId?: unknown }).workflowId) - - const rawTarget = (raw as { reviewTarget?: unknown }).reviewTarget - const nestedTarget = - rawTarget && typeof rawTarget === 'object' ? (rawTarget as Record<string, unknown>) : null - - const reviewTarget: PairReviewTarget = { - reviewSessionId: normalizeOptionalString(nestedTarget?.reviewSessionId), - reviewEntityKind: normalizeOptionalString(nestedTarget?.reviewEntityKind), - reviewEntityId: normalizeOptionalString(nestedTarget?.reviewEntityId), - reviewDraftSessionId: normalizeOptionalString(nestedTarget?.reviewDraftSessionId), - } - - const hasReviewTarget = Object.values(reviewTarget).some(v => v != null) - - const listing = normalizeListingWithResolvedFields((raw as { listing?: unknown }).listing) + const listing = normalizeListingIdentity((raw as { listing?: unknown }).listing) const indicatorId = normalizeOptionalString((raw as { indicatorId?: unknown }).indicatorId) const mcpServerId = normalizeOptionalString((raw as { mcpServerId?: unknown }).mcpServerId) const customToolId = normalizeOptionalString((raw as { customToolId?: unknown }).customToolId) @@ -176,7 +145,6 @@ export function normalizeColorPairsState(state?: unknown): PersistedColorPairsSt color: rawColor, workflowId, listing, - ...(hasReviewTarget ? { reviewTarget } : {}), indicatorId, mcpServerId, customToolId, @@ -235,7 +203,8 @@ export function normalizeDashboardLayout(state?: unknown): LayoutNode { } const node = state as Partial<LayoutNode> - const persistedId = normalizeOptionalString((state as { id?: unknown }).id) ?? createLayoutNodeId() + const persistedId = + normalizeOptionalString((state as { id?: unknown }).id) ?? createLayoutNodeId() if (node.type === 'panel') { return { @@ -293,11 +262,11 @@ export function serializeLayout(node: LayoutNode): PersistedLayoutNode { params: null, } : normalizedParams === widget.params - ? widget - : { - ...widget, - params: normalizedParams ?? null, - } + ? widget + : { + ...widget, + params: normalizedParams ?? null, + } return { id: node.id, type: 'panel', diff --git a/apps/tradinggoose/widgets/registry.test.ts b/apps/tradinggoose/widgets/registry.test.ts new file mode 100644 index 000000000..e235ae6fe --- /dev/null +++ b/apps/tradinggoose/widgets/registry.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { getWidgetCategories, getWidgetDefinition } from '@/widgets/registry' + +describe('widget registry categories', () => { + it('orders selector categories with trading first', () => { + const categories = getWidgetCategories() + + expect(categories.map((category) => category.key)).toEqual([ + 'trading', + 'list', + 'editor', + 'utility', + ]) + expect(categories.map((category) => category.title)).toEqual([ + 'Trading', + 'Lists', + 'Editor', + 'Utils', + ]) + }) + + it('groups trading widgets under Trading and leaves Utils for non-trading widgets', () => { + const categories = getWidgetCategories() + const tradingCategory = categories.find((category) => category.key === 'trading') + const utilityCategory = categories.find((category) => category.key === 'utility') + + expect(tradingCategory?.title).toBe('Trading') + expect(tradingCategory?.widgets.map((widget) => widget.key)).toEqual( + expect.arrayContaining([ + 'data_chart', + 'portfolio_snapshot', + 'quick_order', + 'heatmap', + 'watchlist', + ]) + ) + expect(utilityCategory?.widgets.map((widget) => widget.key)).not.toEqual( + expect.arrayContaining([ + 'data_chart', + 'portfolio_snapshot', + 'quick_order', + 'heatmap', + 'watchlist', + ]) + ) + expect(getWidgetDefinition('heatmap')?.category).toBe('trading') + expect(getWidgetDefinition('watchlist')?.category).toBe('trading') + }) +}) diff --git a/apps/tradinggoose/widgets/registry.tsx b/apps/tradinggoose/widgets/registry.tsx index 8ef189e13..36a045338 100644 --- a/apps/tradinggoose/widgets/registry.tsx +++ b/apps/tradinggoose/widgets/registry.tsx @@ -3,6 +3,9 @@ import type { WidgetCategoryDefinition, WidgetCategoryGroup, } from '@/widgets/types' +import { copilotWidget } from '@/widgets/widgets/copilot' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { dataChartWidget } from '@/widgets/widgets/data_chart' import { editorCustomToolWidget } from '@/widgets/widgets/editor_custom_tool/index' import { editorIndicatorWidget } from '@/widgets/widgets/editor_indicator' @@ -15,13 +18,19 @@ import { listIndicatorWidget } from '@/widgets/widgets/list_indicator' import { listMcpWidget } from '@/widgets/widgets/list_mcp' import { listSkillWidget } from '@/widgets/widgets/list_skill' import { workflowListWidget } from '@/widgets/widgets/list_workflow' +import { portfolioSnapshotWidget } from '@/widgets/widgets/portfolio_snapshot' +import { quickOrderWidget } from '@/widgets/widgets/quick_order' +import { heatmapWidget } from '@/widgets/widgets/heatmap' import { watchlistWidget } from '@/widgets/widgets/watchlist' import { chatWidget } from '@/widgets/widgets/workflow_chat' import { workflowConsoleWidget } from '@/widgets/widgets/workflow_console' -import { copilotWidget } from '@/widgets/widgets/copilot' import { workflowVariablesWidget } from '@/widgets/widgets/workflow_variables' const widgetCategoryConfig: WidgetCategoryDefinition[] = [ + { + key: 'trading', + title: 'Trading', + }, { key: 'list', title: 'Lists', @@ -32,7 +41,7 @@ const widgetCategoryConfig: WidgetCategoryDefinition[] = [ }, { key: 'utility', - title: 'Utility', + title: 'Utils', }, ] @@ -54,6 +63,15 @@ const widgetRegistry: Record<string, DashboardWidgetDefinition> = { editor_skill: editorSkillWidget, workflow_variables: workflowVariablesWidget, watchlist: watchlistWidget, + portfolio_snapshot: portfolioSnapshotWidget, + quick_order: quickOrderWidget, + heatmap: heatmapWidget, +} + +function getLocalizedWidgetTitle(locale: LocaleCode, widget: DashboardWidgetDefinition) { + const widgetsCopy = getPublicCopy(locale).workspace.widgets + const widgetTitle = widgetsCopy.titles[widget.key as keyof typeof widgetsCopy.titles] + return widgetTitle ?? widget.title } export const getWidgetDefinition = (key: string): DashboardWidgetDefinition | undefined => @@ -61,10 +79,15 @@ export const getWidgetDefinition = (key: string): DashboardWidgetDefinition | un export const getAllWidgets = (): DashboardWidgetDefinition[] => Object.values(widgetRegistry) -export const getWidgetCategories = (): WidgetCategoryGroup[] => { +export const getWidgetCategories = (locale: LocaleCode): WidgetCategoryGroup[] => { + const widgetsCopy = getPublicCopy(locale).workspace.widgets const categoryMap = widgetCategoryConfig.reduce<Record<string, WidgetCategoryGroup>>( (acc, category) => { - acc[category.key] = { ...category, widgets: [] } + acc[category.key] = { + ...category, + title: widgetsCopy.selector.categories[category.key], + widgets: [], + } return acc }, {} @@ -73,7 +96,10 @@ export const getWidgetCategories = (): WidgetCategoryGroup[] => { for (const widget of Object.values(widgetRegistry)) { const category = categoryMap[widget.category] if (category) { - category.widgets.push(widget) + category.widgets.push({ + ...widget, + title: getLocalizedWidgetTitle(locale, widget), + }) } } diff --git a/apps/tradinggoose/widgets/types.ts b/apps/tradinggoose/widgets/types.ts index 7db20d7fc..36d51ce80 100644 --- a/apps/tradinggoose/widgets/types.ts +++ b/apps/tradinggoose/widgets/types.ts @@ -1,12 +1,13 @@ import type { ComponentType, ReactNode } from 'react' import type { WidgetInstance } from '@/widgets/layout' import type { PairColor } from '@/widgets/pair-colors' +import type { LocaleCode } from '@/i18n/utils' export type WidgetRuntimeContext = { workspaceId?: string } -export type WidgetCategory = 'editor' | 'list' | 'utility' +export type WidgetCategory = 'editor' | 'list' | 'utility' | 'trading' export interface WidgetCategoryDefinition { key: WidgetCategory @@ -17,6 +18,7 @@ export interface WidgetCategoryDefinition { export interface WidgetComponentProps { params?: Record<string, unknown> | null context?: WidgetRuntimeContext + locale?: LocaleCode pairColor?: PairColor panelId?: string widget?: WidgetInstance | null diff --git a/apps/tradinggoose/widgets/utils/chart-params.test.tsx b/apps/tradinggoose/widgets/utils/chart-params.test.tsx new file mode 100644 index 000000000..a7ccfaad7 --- /dev/null +++ b/apps/tradinggoose/widgets/utils/chart-params.test.tsx @@ -0,0 +1,107 @@ +/** + * @vitest-environment jsdom + */ + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + emitDataChartParamsChange, + sanitizeDataChartParams, + useDataChartParamsPersistence, +} from '@/widgets/utils/chart-params' + +function Harness({ + params, + onWidgetParamsChange, +}: { + params: Record<string, unknown> | null + onWidgetParamsChange: (params: Record<string, unknown> | null) => void +}) { + useDataChartParamsPersistence({ + panelId: 'panel-1', + widget: { key: 'data_chart' } as any, + params, + onWidgetParamsChange, + }) + + return null +} + +describe('sanitizeDataChartParams', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('preserves raw and env-var nested market auth for data chart provider params', () => { + expect( + sanitizeDataChartParams({ + data: { + provider: 'alpaca', + auth: { + apiKey: 'raw-key', + apiSecret: '{{ ALPACA_API_SECRET }}', + }, + }, + }) + ).toEqual({ + data: { + provider: 'alpaca', + auth: { + apiKey: 'raw-key', + apiSecret: '{{ ALPACA_API_SECRET }}', + }, + }, + }) + }) + + it('uses sanitized params as the persistence comparison baseline', async () => { + const onWidgetParamsChange = vi.fn() + + await act(async () => { + root.render( + createElement(Harness, { + params: { + data: { + provider: 'alpaca', + auth: { + apiKey: 'raw-key', + }, + }, + }, + onWidgetParamsChange, + }) + ) + }) + + await act(async () => { + emitDataChartParamsChange({ + params: { + data: { + provider: 'alpaca', + }, + }, + panelId: 'panel-1', + widgetKey: 'data_chart', + }) + }) + + expect(onWidgetParamsChange).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/widgets/utils/chart-params.ts b/apps/tradinggoose/widgets/utils/chart-params.ts index 90921af0a..bf7a39802 100644 --- a/apps/tradinggoose/widgets/utils/chart-params.ts +++ b/apps/tradinggoose/widgets/utils/chart-params.ts @@ -1,11 +1,15 @@ import { useEffect, useRef } from 'react' -import type { WidgetInstance } from '@/widgets/layout' -import type { ManualOwnerSnapshot } from '@/widgets/widgets/data_chart/drawings/snapshot' -import { normalizeManualOwnerSnapshot } from '@/widgets/widgets/data_chart/drawings/snapshot' +import { + sanitizeMarketProviderAuth, + sanitizeMarketProviderParamsForWidget, +} from '@/lib/market/market-provider-settings' import { DATA_CHART_WIDGET_UPDATE_PARAMS_EVENT, type DataChartWidgetUpdateEventDetail, } from '@/widgets/events' +import type { WidgetInstance } from '@/widgets/layout' +import type { ManualOwnerSnapshot } from '@/widgets/widgets/data_chart/drawings/snapshot' +import { normalizeManualOwnerSnapshot } from '@/widgets/widgets/data_chart/drawings/snapshot' interface UseDataChartParamsPersistenceOptions { onWidgetParamsChange?: (params: Record<string, unknown> | null) => void @@ -92,7 +96,7 @@ const mergeDrawToolsSnapshots = ( const id = typeof entry.id === 'string' ? entry.id.trim() : '' if (!id) return entry - const hasExplicitSnapshotField = Object.prototype.hasOwnProperty.call(entry, 'snapshot') + const hasExplicitSnapshotField = Object.hasOwn(entry, 'snapshot') if (hasExplicitSnapshotField) { return entry } @@ -143,7 +147,47 @@ const mergeNestedParams = ( : { ...incomingRuntime } } - return merged + return sanitizeDataChartParams(merged) ?? {} +} + +export const sanitizeDataChartParams = ( + params: Record<string, unknown> | null | undefined +): Record<string, unknown> | null => { + if (!params || !isRecord(params)) return null + + const nextParams: Record<string, unknown> = { ...params } + const data = isRecord(params.data) ? params.data : null + + if (data) { + const provider = typeof data.provider === 'string' ? data.provider.trim() : '' + const providerParams = sanitizeMarketProviderParamsForWidget(provider, data.providerParams) + const auth = sanitizeMarketProviderAuth(data.auth) + const nextData = Object.entries(data).reduce<Record<string, unknown>>((acc, [key, value]) => { + if (key !== 'provider' && key !== 'providerParams' && key !== 'auth') { + acc[key] = value + } + return acc + }, {}) + + if (provider) { + nextData.provider = provider + } + + if (providerParams) { + nextData.providerParams = providerParams + } + + if (auth) { + nextData.auth = auth + } + + nextParams.data = Object.keys(nextData).length > 0 ? nextData : undefined + } + + return Object.entries(nextParams).reduce<Record<string, unknown>>((acc, [key, value]) => { + if (value !== undefined) acc[key] = value + return acc + }, {}) } export function useDataChartParamsPersistence({ @@ -152,13 +196,10 @@ export function useDataChartParamsPersistence({ widget, params, }: UseDataChartParamsPersistenceOptions) { - const latestParamsRef = useRef<Record<string, unknown> | null>( - params && typeof params === 'object' ? (params as Record<string, unknown>) : null - ) + const latestParamsRef = useRef<Record<string, unknown> | null>(sanitizeDataChartParams(params)) useEffect(() => { - latestParamsRef.current = - params && typeof params === 'object' ? (params as Record<string, unknown>) : null + latestParamsRef.current = sanitizeDataChartParams(params) }, [params]) useEffect(() => { diff --git a/apps/tradinggoose/widgets/utils/heatmap-params.test.ts b/apps/tradinggoose/widgets/utils/heatmap-params.test.ts new file mode 100644 index 000000000..ee89443ac --- /dev/null +++ b/apps/tradinggoose/widgets/utils/heatmap-params.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeHeatmapParams } from '@/widgets/utils/heatmap-params' + +describe('sanitizeHeatmapParams', () => { + it('persists source/provider selections with raw and env-var market credentials', () => { + expect( + sanitizeHeatmapParams({ + sourceMode: 'portfolio', + watchlistSizeMetric: 'volumeUsd', + marketProvider: 'alpaca', + marketAuth: { + apiKey: 'raw-key', + apiSecret: '{{ ALPACA_API_SECRET }}', + }, + tradingProvider: 'alpaca', + accountId: 'account-1', + runtime: { refreshAt: 123 }, + }) + ).toEqual({ + sourceMode: 'portfolio', + watchlistSizeMetric: 'volumeUsd', + marketProvider: 'alpaca', + marketAuth: { + apiKey: 'raw-key', + apiSecret: '{{ ALPACA_API_SECRET }}', + }, + tradingProvider: 'alpaca', + accountId: 'account-1', + runtime: { refreshAt: 123 }, + }) + }) +}) diff --git a/apps/tradinggoose/widgets/utils/heatmap-params.ts b/apps/tradinggoose/widgets/utils/heatmap-params.ts new file mode 100644 index 000000000..db3f3ca1f --- /dev/null +++ b/apps/tradinggoose/widgets/utils/heatmap-params.ts @@ -0,0 +1,164 @@ +import { useEffect, useRef } from 'react' +import { + sanitizeMarketProviderAuth, + sanitizeMarketProviderParamsForWidget, +} from '@/lib/market/market-provider-settings' +import { + HEATMAP_WIDGET_UPDATE_PARAMS_EVENT, + type HeatmapWidgetUpdateEventDetail, +} from '@/widgets/events' +import type { WidgetInstance } from '@/widgets/layout' + +interface UseHeatmapParamsPersistenceOptions { + onWidgetParamsChange?: (params: Record<string, unknown> | null) => void + panelId?: string + widget?: WidgetInstance | null + params?: Record<string, unknown> | null +} + +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const normalizeString = (value: unknown) => { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed || undefined +} + +const areValuesEqual = (left: unknown, right: unknown): boolean => { + if (Object.is(left, right)) return true + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false + if (left.length !== right.length) return false + return left.every((value, index) => areValuesEqual(value, right[index])) + } + + if (isRecord(left) || isRecord(right)) { + if (!isRecord(left) || !isRecord(right)) return false + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + if (leftKeys.length !== rightKeys.length) return false + return leftKeys.every((key) => key in right && areValuesEqual(left[key], right[key])) + } + + return false +} + +export const sanitizeHeatmapParams = ( + params: Record<string, unknown> | null | undefined +): Record<string, unknown> | null => { + if (!params || !isRecord(params)) return null + + const sourceMode = normalizeString(params.sourceMode) + const watchlistSizeMetric = normalizeString(params.watchlistSizeMetric) + const marketProvider = normalizeString(params.marketProvider) + const tradingProvider = normalizeString(params.tradingProvider) + const credentialServiceId = normalizeString(params.credentialServiceId) + const accountId = normalizeString(params.accountId) + const marketProviderParams = sanitizeMarketProviderParamsForWidget( + marketProvider, + params.marketProviderParams + ) + const marketAuth = sanitizeMarketProviderAuth(params.marketAuth) + const runtime = isRecord(params.runtime) ? params.runtime : null + const refreshAt = + typeof runtime?.refreshAt === 'number' && Number.isFinite(runtime.refreshAt) + ? runtime.refreshAt + : undefined + + const nextParams: Record<string, unknown> = {} + if (sourceMode === 'watchlist' || sourceMode === 'portfolio') nextParams.sourceMode = sourceMode + if (watchlistSizeMetric === 'volume' || watchlistSizeMetric === 'volumeUsd') { + nextParams.watchlistSizeMetric = watchlistSizeMetric + } + if (marketProvider) nextParams.marketProvider = marketProvider + if (marketProviderParams) nextParams.marketProviderParams = marketProviderParams + if (marketAuth) nextParams.marketAuth = marketAuth + if (tradingProvider) nextParams.tradingProvider = tradingProvider + if (credentialServiceId) nextParams.credentialServiceId = credentialServiceId + if (accountId) nextParams.accountId = accountId + if (refreshAt !== undefined) nextParams.runtime = { refreshAt } + + return Object.keys(nextParams).length > 0 ? nextParams : null +} + +const mergeHeatmapParams = ( + currentParams: Record<string, unknown> | null | undefined, + incomingParams: Record<string, unknown> +) => { + const currentRuntime = isRecord(currentParams?.runtime) ? currentParams.runtime : null + const incomingRuntime = isRecord(incomingParams.runtime) ? incomingParams.runtime : null + const mergedRuntime = + currentRuntime || incomingRuntime + ? { + ...(currentRuntime ?? {}), + ...(incomingRuntime ?? {}), + } + : undefined + + return sanitizeHeatmapParams({ + ...(currentParams ?? {}), + ...incomingParams, + ...(mergedRuntime ? { runtime: mergedRuntime } : {}), + }) +} + +export function useHeatmapParamsPersistence({ + onWidgetParamsChange, + panelId, + widget, + params, +}: UseHeatmapParamsPersistenceOptions) { + const latestParamsRef = useRef<Record<string, unknown> | null>(sanitizeHeatmapParams(params)) + + useEffect(() => { + latestParamsRef.current = sanitizeHeatmapParams(params) + }, [params]) + + useEffect(() => { + if (!onWidgetParamsChange) return + + const handleParamsUpdate = (event: Event) => { + const detail = (event as CustomEvent<HeatmapWidgetUpdateEventDetail>).detail + if (!detail?.params || !isRecord(detail.params)) return + if (panelId && detail.panelId && detail.panelId !== panelId) return + if (widget?.key && detail.widgetKey && detail.widgetKey !== widget.key) return + + const currentParams = latestParamsRef.current + const nextParams = mergeHeatmapParams(currentParams, detail.params) + if (areValuesEqual(currentParams, nextParams)) return + + latestParamsRef.current = nextParams + onWidgetParamsChange(nextParams) + } + + window.addEventListener(HEATMAP_WIDGET_UPDATE_PARAMS_EVENT, handleParamsUpdate) + + return () => { + window.removeEventListener(HEATMAP_WIDGET_UPDATE_PARAMS_EVENT, handleParamsUpdate) + } + }, [onWidgetParamsChange, panelId, widget?.key]) +} + +export function emitHeatmapParamsChange({ + params, + panelId, + widgetKey, +}: { + params: Record<string, unknown> + panelId?: string + widgetKey?: string +}) { + if (!params || Object.keys(params).length === 0) return + + window.dispatchEvent( + new CustomEvent<HeatmapWidgetUpdateEventDetail>(HEATMAP_WIDGET_UPDATE_PARAMS_EVENT, { + detail: { + params, + panelId, + widgetKey, + }, + }) + ) +} diff --git a/apps/tradinggoose/widgets/utils/portfolio-snapshot-params.test.tsx b/apps/tradinggoose/widgets/utils/portfolio-snapshot-params.test.tsx new file mode 100644 index 000000000..06748938c --- /dev/null +++ b/apps/tradinggoose/widgets/utils/portfolio-snapshot-params.test.tsx @@ -0,0 +1,113 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + emitPortfolioSnapshotParamsChange, + sanitizePortfolioSnapshotParams, + usePortfolioSnapshotParamsPersistence, +} from '@/widgets/utils/portfolio-snapshot-params' + +function Harness({ + params, + onWidgetParamsChange, +}: { + params: Record<string, unknown> | null + onWidgetParamsChange: (params: Record<string, unknown> | null) => void +}) { + usePortfolioSnapshotParamsPersistence({ + panelId: 'panel-1', + widget: { key: 'portfolio_snapshot' } as any, + params, + onWidgetParamsChange, + }) + + return null +} + +describe('portfolio snapshot params helper', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.restoreAllMocks() + }) + + it('sanitizes the persisted shape down to supported keys', () => { + expect( + sanitizePortfolioSnapshotParams({ + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: '1D', + ignored: true, + runtime: { + refreshAt: 123, + ignored: 'x', + }, + }) + ).toEqual({ + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: '1D', + runtime: { + refreshAt: 123, + }, + }) + }) + + it('merges runtime.refreshAt updates without keeping unsupported keys', async () => { + const onWidgetParamsChange = vi.fn() + + await act(async () => { + root.render( + <Harness + params={{ + provider: 'alpaca', + runtime: { + refreshAt: 100, + }, + }} + onWidgetParamsChange={onWidgetParamsChange} + /> + ) + }) + + await act(async () => { + emitPortfolioSnapshotParamsChange({ + params: { + runtime: { + refreshAt: 200, + ignored: 'value', + }, + ignored: true, + }, + panelId: 'panel-1', + widgetKey: 'portfolio_snapshot', + }) + }) + + expect(onWidgetParamsChange).toHaveBeenCalledWith({ + provider: 'alpaca', + runtime: { + refreshAt: 200, + }, + }) + }) +}) diff --git a/apps/tradinggoose/widgets/utils/portfolio-snapshot-params.ts b/apps/tradinggoose/widgets/utils/portfolio-snapshot-params.ts new file mode 100644 index 000000000..85da34239 --- /dev/null +++ b/apps/tradinggoose/widgets/utils/portfolio-snapshot-params.ts @@ -0,0 +1,199 @@ +import { useEffect, useRef } from 'react' +import { + sanitizeMarketProviderAuth, + sanitizeMarketProviderParamsForWidget, +} from '@/lib/market/market-provider-settings' +import { + PORTFOLIO_SNAPSHOT_WIDGET_UPDATE_PARAMS_EVENT, + type PortfolioSnapshotWidgetUpdateEventDetail, +} from '@/widgets/events' +import type { WidgetInstance } from '@/widgets/layout' + +interface UsePortfolioSnapshotParamsPersistenceOptions { + onWidgetParamsChange?: (params: Record<string, unknown> | null) => void + panelId?: string + widget?: WidgetInstance | null + params?: Record<string, unknown> | null +} + +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const areValuesEqual = (left: unknown, right: unknown): boolean => { + if (Object.is(left, right)) return true + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false + if (left.length !== right.length) return false + + for (let index = 0; index < left.length; index += 1) { + if (!areValuesEqual(left[index], right[index])) { + return false + } + } + + return true + } + + if (isRecord(left) || isRecord(right)) { + if (!isRecord(left) || !isRecord(right)) return false + + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + if (leftKeys.length !== rightKeys.length) return false + + for (const key of leftKeys) { + if (!(key in right)) return false + if (!areValuesEqual(left[key], right[key])) return false + } + + return true + } + + return false +} + +const normalizeString = (value: unknown) => { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed || undefined +} + +export const sanitizePortfolioSnapshotParams = ( + params: Record<string, unknown> | null | undefined +): Record<string, unknown> | null => { + if (!params || !isRecord(params)) return null + + const runtime = isRecord(params.runtime) ? params.runtime : null + const refreshAt = + typeof runtime?.refreshAt === 'number' && Number.isFinite(runtime.refreshAt) + ? runtime.refreshAt + : undefined + + const nextParams: Record<string, unknown> = {} + const provider = normalizeString(params.provider) + const credentialServiceId = normalizeString(params.credentialServiceId) + const marketProvider = normalizeString(params.marketProvider) + const accountId = normalizeString(params.accountId) + const selectedWindow = normalizeString(params.selectedWindow) + + if (provider) nextParams.provider = provider + if (credentialServiceId) nextParams.credentialServiceId = credentialServiceId + if (marketProvider) nextParams.marketProvider = marketProvider + if (accountId) nextParams.accountId = accountId + if (selectedWindow) nextParams.selectedWindow = selectedWindow + const marketProviderParams = sanitizeMarketProviderParamsForWidget( + marketProvider, + params.marketProviderParams + ) + const marketAuth = sanitizeMarketProviderAuth(params.marketAuth) + if (marketProviderParams) nextParams.marketProviderParams = marketProviderParams + if (marketAuth) nextParams.marketAuth = marketAuth + if (refreshAt !== undefined) { + nextParams.runtime = { + refreshAt, + } + } + + return Object.keys(nextParams).length > 0 ? nextParams : null +} + +const mergePortfolioSnapshotParams = ( + currentParams: Record<string, unknown> | null | undefined, + incomingParams: Record<string, unknown> +) => { + const currentRuntime = isRecord(currentParams?.runtime) ? currentParams.runtime : null + const incomingRuntime = isRecord(incomingParams.runtime) ? incomingParams.runtime : null + const mergedRuntime = + currentRuntime || incomingRuntime + ? { + ...(currentRuntime ?? {}), + ...(incomingRuntime ?? {}), + } + : undefined + + const merged = { + ...(currentParams ?? {}), + ...incomingParams, + ...(mergedRuntime ? { runtime: mergedRuntime } : {}), + } + + return sanitizePortfolioSnapshotParams(merged) +} + +export function usePortfolioSnapshotParamsPersistence({ + onWidgetParamsChange, + panelId, + widget, + params, +}: UsePortfolioSnapshotParamsPersistenceOptions) { + const latestParamsRef = useRef<Record<string, unknown> | null>( + sanitizePortfolioSnapshotParams(params) + ) + + useEffect(() => { + latestParamsRef.current = sanitizePortfolioSnapshotParams(params) + }, [params]) + + useEffect(() => { + if (!onWidgetParamsChange) { + return + } + + const handleParamsUpdate = (event: Event) => { + const detail = (event as CustomEvent<PortfolioSnapshotWidgetUpdateEventDetail>).detail + if (!detail?.params || !isRecord(detail.params)) return + if (panelId && detail.panelId && detail.panelId !== panelId) return + if (widget?.key && detail.widgetKey && detail.widgetKey !== widget.key) return + + const currentParams = latestParamsRef.current + const nextParams = mergePortfolioSnapshotParams(currentParams, detail.params) + + if (areValuesEqual(currentParams, nextParams)) { + return + } + + latestParamsRef.current = nextParams + onWidgetParamsChange(nextParams) + } + + window.addEventListener( + PORTFOLIO_SNAPSHOT_WIDGET_UPDATE_PARAMS_EVENT, + handleParamsUpdate as EventListener + ) + + return () => { + window.removeEventListener( + PORTFOLIO_SNAPSHOT_WIDGET_UPDATE_PARAMS_EVENT, + handleParamsUpdate as EventListener + ) + } + }, [onWidgetParamsChange, panelId, widget?.key]) +} + +export function emitPortfolioSnapshotParamsChange({ + params, + panelId, + widgetKey, +}: { + params: Record<string, unknown> + panelId?: string + widgetKey?: string +}) { + if (!params || Object.keys(params).length === 0) { + return + } + + window.dispatchEvent( + new CustomEvent<PortfolioSnapshotWidgetUpdateEventDetail>( + PORTFOLIO_SNAPSHOT_WIDGET_UPDATE_PARAMS_EVENT, + { + detail: { + params, + panelId, + widgetKey, + }, + } + ) + ) +} diff --git a/apps/tradinggoose/widgets/utils/quick-order-params.test.tsx b/apps/tradinggoose/widgets/utils/quick-order-params.test.tsx new file mode 100644 index 000000000..a52afccea --- /dev/null +++ b/apps/tradinggoose/widgets/utils/quick-order-params.test.tsx @@ -0,0 +1,180 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + emitQuickOrderParamsChange, + sanitizeQuickOrderParams, + useQuickOrderParamsPersistence, +} from '@/widgets/utils/quick-order-params' + +function Harness({ + params, + onChange, +}: { + params?: Record<string, unknown> | null + onChange: (params: Record<string, unknown> | null) => void +}) { + useQuickOrderParamsPersistence({ + params, + onWidgetParamsChange: onChange, + panelId: 'panel-1', + widget: { key: 'quick_order' } as any, + }) + return null +} + +describe('quick order params utilities', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('keeps only header-level quick order params', () => { + expect( + sanitizeQuickOrderParams({ + provider: ' alpaca ', + marketProvider: ' yahoo-finance ', + marketProviderParams: { + region: 'US', + apiKey: 'not-persisted-here', + }, + marketAuth: { + apiKey: 'market-key', + apiSecret: 'market-secret', + }, + accountId: 'acct-1', + side: 'sell', + quantity: 1, + orderClass: 'equity', + providerParams: { orderClass: 'equity' }, + }) + ).toEqual({ + provider: 'alpaca', + marketProvider: 'yahoo-finance', + marketProviderParams: { + region: 'US', + }, + marketAuth: { + apiKey: 'market-key', + apiSecret: 'market-secret', + }, + accountId: 'acct-1', + side: 'sell', + }) + }) + + it('merges scoped header update events', async () => { + const onChange = vi.fn() + + await act(async () => { + root.render(<Harness params={{ provider: 'alpaca', side: 'buy' }} onChange={onChange} />) + }) + + act(() => { + emitQuickOrderParamsChange({ + params: { side: 'sell' }, + panelId: 'other-panel', + widgetKey: 'quick_order', + }) + emitQuickOrderParamsChange({ + params: { side: 'sell' }, + panelId: 'panel-1', + widgetKey: 'quick_order', + }) + }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith({ provider: 'alpaca', side: 'sell' }) + }) + + it('uses null event values to clear stale persisted selections', async () => { + const onChange = vi.fn() + + await act(async () => { + root.render( + <Harness + params={{ + provider: 'alpaca', + accountId: 'acct-1', + side: 'buy', + }} + onChange={onChange} + /> + ) + }) + + act(() => { + emitQuickOrderParamsChange({ + params: { + accountId: null, + quantity: 10, + providerParams: { orderClass: 'equity' }, + }, + panelId: 'panel-1', + widgetKey: 'quick_order', + }) + }) + + expect(onChange).toHaveBeenCalledWith({ + provider: 'alpaca', + side: 'buy', + }) + }) + + it('merges market data provider updates separately from trading provider settings', async () => { + const onChange = vi.fn() + + await act(async () => { + root.render( + <Harness + params={{ + provider: 'alpaca', + marketProvider: 'yahoo-finance', + marketProviderParams: { region: 'US' }, + marketAuth: { apiKey: 'market-key' }, + accountId: 'acct-1', + side: 'buy', + }} + onChange={onChange} + /> + ) + }) + + act(() => { + emitQuickOrderParamsChange({ + params: { + marketProvider: 'finnhub', + marketProviderParams: null, + marketAuth: null, + }, + panelId: 'panel-1', + widgetKey: 'quick_order', + }) + }) + + expect(onChange).toHaveBeenCalledWith({ + provider: 'alpaca', + marketProvider: 'finnhub', + accountId: 'acct-1', + side: 'buy', + }) + }) +}) diff --git a/apps/tradinggoose/widgets/utils/quick-order-params.ts b/apps/tradinggoose/widgets/utils/quick-order-params.ts new file mode 100644 index 000000000..8c1002943 --- /dev/null +++ b/apps/tradinggoose/widgets/utils/quick-order-params.ts @@ -0,0 +1,142 @@ +import { useEffect, useRef } from 'react' +import { + sanitizeMarketProviderAuth, + sanitizeMarketProviderParamsForWidget, +} from '@/lib/market/market-provider-settings' +import { + QUICK_ORDER_WIDGET_UPDATE_PARAMS_EVENT, + type QuickOrderWidgetUpdateEventDetail, +} from '@/widgets/events' +import type { WidgetInstance } from '@/widgets/layout' + +interface UseQuickOrderParamsPersistenceOptions { + onWidgetParamsChange?: (params: Record<string, unknown> | null) => void + panelId?: string + widget?: WidgetInstance | null + params?: Record<string, unknown> | null +} + +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const normalizeString = (value: unknown) => { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed || undefined +} + +const areValuesEqual = (left: unknown, right: unknown): boolean => { + if (Object.is(left, right)) return true + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false + if (left.length !== right.length) return false + return left.every((value, index) => areValuesEqual(value, right[index])) + } + + if (!isRecord(left) || !isRecord(right)) return false + + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + if (leftKeys.length !== rightKeys.length) return false + + for (const key of leftKeys) { + if (!(key in right)) return false + if (!areValuesEqual(left[key], right[key])) return false + } + + return true +} + +export const sanitizeQuickOrderParams = ( + params: Record<string, unknown> | null | undefined +): Record<string, unknown> | null => { + if (!params || !isRecord(params)) return null + + const nextParams: Record<string, unknown> = {} + const provider = normalizeString(params.provider) + const credentialServiceId = normalizeString(params.credentialServiceId) + const marketProvider = normalizeString(params.marketProvider) + const accountId = normalizeString(params.accountId) + const side = normalizeString(params.side) + + if (provider) nextParams.provider = provider + if (credentialServiceId) nextParams.credentialServiceId = credentialServiceId + if (marketProvider) nextParams.marketProvider = marketProvider + if (accountId) nextParams.accountId = accountId + if (side === 'buy' || side === 'sell') nextParams.side = side + const marketProviderParams = sanitizeMarketProviderParamsForWidget( + marketProvider, + params.marketProviderParams + ) + const marketAuth = sanitizeMarketProviderAuth(params.marketAuth) + if (marketProviderParams) nextParams.marketProviderParams = marketProviderParams + if (marketAuth) nextParams.marketAuth = marketAuth + + return Object.keys(nextParams).length > 0 ? nextParams : null +} + +const mergeQuickOrderParams = ( + currentParams: Record<string, unknown> | null | undefined, + incomingParams: Record<string, unknown> +) => sanitizeQuickOrderParams({ ...(currentParams ?? {}), ...incomingParams }) + +export function useQuickOrderParamsPersistence({ + onWidgetParamsChange, + panelId, + widget, + params, +}: UseQuickOrderParamsPersistenceOptions) { + const latestParamsRef = useRef<Record<string, unknown> | null>(sanitizeQuickOrderParams(params)) + + useEffect(() => { + latestParamsRef.current = sanitizeQuickOrderParams(params) + }, [params]) + + useEffect(() => { + if (!onWidgetParamsChange) return + + const handleParamsUpdate = (event: Event) => { + const detail = (event as CustomEvent<QuickOrderWidgetUpdateEventDetail>).detail + if (!detail?.params || !isRecord(detail.params)) return + if (panelId && detail.panelId && detail.panelId !== panelId) return + if (widget?.key && detail.widgetKey && detail.widgetKey !== widget.key) return + + const currentParams = latestParamsRef.current + const nextParams = mergeQuickOrderParams(currentParams, detail.params) + + if (areValuesEqual(currentParams, nextParams)) return + + latestParamsRef.current = nextParams + onWidgetParamsChange(nextParams) + } + + window.addEventListener(QUICK_ORDER_WIDGET_UPDATE_PARAMS_EVENT, handleParamsUpdate) + + return () => { + window.removeEventListener(QUICK_ORDER_WIDGET_UPDATE_PARAMS_EVENT, handleParamsUpdate) + } + }, [onWidgetParamsChange, panelId, widget?.key]) +} + +export function emitQuickOrderParamsChange({ + params, + panelId, + widgetKey, +}: { + params: Record<string, unknown> + panelId?: string + widgetKey?: string +}) { + if (!params || Object.keys(params).length === 0) return + + window.dispatchEvent( + new CustomEvent<QuickOrderWidgetUpdateEventDetail>(QUICK_ORDER_WIDGET_UPDATE_PARAMS_EVENT, { + detail: { + params, + panelId, + widgetKey, + }, + }) + ) +} diff --git a/apps/tradinggoose/widgets/utils/trading-widget-providers.test.ts b/apps/tradinggoose/widgets/utils/trading-widget-providers.test.ts new file mode 100644 index 000000000..29229d888 --- /dev/null +++ b/apps/tradinggoose/widgets/utils/trading-widget-providers.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { + getTradingWidgetProviderOptions, + resolveTradingWidgetProviderId, +} from '@/widgets/utils/trading-widget-providers' + +describe('trading widget provider helpers', () => { + it('filters provider options by availability and resolves invalid persisted providers', () => { + const options = getTradingWidgetProviderOptions('holdings', { + 'alpaca-paper': true, + tradier: false, + }) + + expect(options.map((option) => option.id)).toEqual(['alpaca']) + expect(resolveTradingWidgetProviderId('tradier', options)).toBe('') + expect(resolveTradingWidgetProviderId('alpaca', options)).toBe('alpaca') + }) +}) diff --git a/apps/tradinggoose/widgets/utils/trading-widget-providers.ts b/apps/tradinggoose/widgets/utils/trading-widget-providers.ts new file mode 100644 index 000000000..076f0c569 --- /dev/null +++ b/apps/tradinggoose/widgets/utils/trading-widget-providers.ts @@ -0,0 +1,29 @@ +import { + getAvailableTradingProviderOptions, + getTradingProviderOAuthServiceIds, + getTradingProviderOptionsByKind, + getTradingProvidersByKind, +} from '@/providers/trading/providers' +import type { TradingOperationKind } from '@/providers/trading/types' + +export const getTradingWidgetProviderAvailabilityIds = (kind: TradingOperationKind): string[] => + getTradingProvidersByKind(kind).flatMap((provider) => + getTradingProviderOAuthServiceIds(provider.id) + ) + +export const getTradingWidgetProviderOptions = ( + kind: TradingOperationKind, + providerAvailability?: Record<string, boolean> +): Array<{ id: string; name: string }> => + providerAvailability + ? getAvailableTradingProviderOptions(providerAvailability, kind) + : getTradingProviderOptionsByKind(kind) + +export const resolveTradingWidgetProviderId = ( + provider: unknown, + providerOptions: Array<{ id: string; name: string }> +): string => { + const providerId = typeof provider === 'string' ? provider.trim() : '' + if (!providerId) return '' + return providerOptions.some((option) => option.id === providerId) ? providerId : '' +} diff --git a/apps/tradinggoose/widgets/utils/watchlist-params.test.tsx b/apps/tradinggoose/widgets/utils/watchlist-params.test.tsx new file mode 100644 index 000000000..e949dc46a --- /dev/null +++ b/apps/tradinggoose/widgets/utils/watchlist-params.test.tsx @@ -0,0 +1,127 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + emitWatchlistParamsChange, + sanitizeWatchlistParams, + useWatchlistParamsPersistence, +} from '@/widgets/utils/watchlist-params' + +function Harness({ + params, + onWidgetParamsChange, +}: { + params: Record<string, unknown> | null + onWidgetParamsChange: (params: Record<string, unknown> | null) => void +}) { + useWatchlistParamsPersistence({ + panelId: 'panel-1', + widget: { key: 'watchlist' } as any, + params, + onWidgetParamsChange, + }) + + return null +} + +describe('sanitizeWatchlistParams', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('preserves raw and env-var market auth', () => { + expect( + sanitizeWatchlistParams({ + provider: 'alpaca', + watchlistId: 'watchlist-1', + auth: { + apiKey: 'raw-key', + apiSecret: '{{ ALPACA_API_SECRET }}', + }, + }) + ).toEqual({ + provider: 'alpaca', + watchlistId: 'watchlist-1', + auth: { + apiKey: 'raw-key', + apiSecret: '{{ ALPACA_API_SECRET }}', + }, + }) + }) + + it('merges runtime refresh updates against the latest sanitized params', async () => { + const onWidgetParamsChange = vi.fn() + + await act(async () => { + root.render( + <Harness + params={{ + provider: 'alpaca', + runtime: { + refreshAt: 100, + }, + }} + onWidgetParamsChange={onWidgetParamsChange} + /> + ) + }) + + await act(async () => { + emitWatchlistParamsChange({ + params: { + watchlistId: 'watchlist-1', + runtime: { + refreshAt: 200, + ignored: 'value', + }, + ignored: true, + }, + panelId: 'panel-1', + widgetKey: 'watchlist', + }) + }) + + expect(onWidgetParamsChange).toHaveBeenCalledWith({ + provider: 'alpaca', + watchlistId: 'watchlist-1', + runtime: { + refreshAt: 200, + }, + }) + + await act(async () => { + emitWatchlistParamsChange({ + params: { + watchlistId: 'watchlist-1', + runtime: { + refreshAt: 200, + }, + }, + panelId: 'panel-1', + widgetKey: 'watchlist', + }) + }) + + expect(onWidgetParamsChange).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/tradinggoose/widgets/utils/watchlist-params.ts b/apps/tradinggoose/widgets/utils/watchlist-params.ts index adc3d5e8f..01915d85f 100644 --- a/apps/tradinggoose/widgets/utils/watchlist-params.ts +++ b/apps/tradinggoose/widgets/utils/watchlist-params.ts @@ -1,9 +1,13 @@ -import { useEffect } from 'react' -import type { WidgetInstance } from '@/widgets/layout' +import { useEffect, useRef } from 'react' +import { + sanitizeMarketProviderAuth, + sanitizeMarketProviderParamsForWidget, +} from '@/lib/market/market-provider-settings' import { WATCHLIST_WIDGET_UPDATE_PARAMS_EVENT, type WatchlistWidgetUpdateEventDetail, } from '@/widgets/events' +import type { WidgetInstance } from '@/widgets/layout' interface UseWatchlistParamsPersistenceOptions { onWidgetParamsChange?: (params: Record<string, unknown> | null) => void @@ -12,28 +16,109 @@ interface UseWatchlistParamsPersistenceOptions { params?: Record<string, unknown> | null } +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const areValuesEqual = (left: unknown, right: unknown): boolean => { + if (Object.is(left, right)) return true + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false + if (left.length !== right.length) return false + return left.every((value, index) => areValuesEqual(value, right[index])) + } + + if (isRecord(left) || isRecord(right)) { + if (!isRecord(left) || !isRecord(right)) return false + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + if (leftKeys.length !== rightKeys.length) return false + return leftKeys.every((key) => key in right && areValuesEqual(left[key], right[key])) + } + + return false +} + +const normalizeString = (value: unknown) => { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed || undefined +} + +export const sanitizeWatchlistParams = ( + params: Record<string, unknown> | null | undefined +): Record<string, unknown> | null => { + if (!params || !isRecord(params)) return null + + const provider = normalizeString(params.provider) + const watchlistId = normalizeString(params.watchlistId) + const providerParams = sanitizeMarketProviderParamsForWidget(provider, params.providerParams) + const auth = sanitizeMarketProviderAuth(params.auth) + const runtime = isRecord(params.runtime) ? params.runtime : null + const refreshAt = + typeof runtime?.refreshAt === 'number' && Number.isFinite(runtime.refreshAt) + ? runtime.refreshAt + : undefined + + const nextParams: Record<string, unknown> = {} + if (provider) nextParams.provider = provider + if (watchlistId) nextParams.watchlistId = watchlistId + if (providerParams) nextParams.providerParams = providerParams + if (auth) nextParams.auth = auth + if (refreshAt !== undefined) nextParams.runtime = { refreshAt } + + return Object.keys(nextParams).length > 0 ? nextParams : null +} + +const mergeWatchlistParams = ( + currentParams: Record<string, unknown> | null | undefined, + incomingParams: Record<string, unknown> +) => { + const currentRuntime = isRecord(currentParams?.runtime) ? currentParams.runtime : null + const incomingRuntime = isRecord(incomingParams.runtime) ? incomingParams.runtime : null + const mergedRuntime = + currentRuntime || incomingRuntime + ? { + ...(currentRuntime ?? {}), + ...(incomingRuntime ?? {}), + } + : undefined + + return sanitizeWatchlistParams({ + ...(currentParams ?? {}), + ...incomingParams, + ...(mergedRuntime ? { runtime: mergedRuntime } : {}), + }) +} + export function useWatchlistParamsPersistence({ onWidgetParamsChange, panelId, widget, params, }: UseWatchlistParamsPersistenceOptions) { + const latestParamsRef = useRef<Record<string, unknown> | null>(sanitizeWatchlistParams(params)) + + useEffect(() => { + latestParamsRef.current = sanitizeWatchlistParams(params) + }, [params]) + useEffect(() => { if (!onWidgetParamsChange) return const handleParamsUpdate = (event: Event) => { const detail = (event as CustomEvent<WatchlistWidgetUpdateEventDetail>).detail - if (!detail?.params || typeof detail.params !== 'object') return + if (!detail?.params || !isRecord(detail.params)) return if (panelId && detail.panelId && detail.panelId !== panelId) return if (widget?.key && detail.widgetKey && detail.widgetKey !== widget.key) return - const currentParams = - params && typeof params === 'object' ? (params as Record<string, unknown>) : {} + const currentParams = latestParamsRef.current + const nextParams = mergeWatchlistParams(currentParams, detail.params) + + if (areValuesEqual(currentParams, nextParams)) return - onWidgetParamsChange({ - ...currentParams, - ...detail.params, - }) + latestParamsRef.current = nextParams + onWidgetParamsChange(nextParams) } window.addEventListener( @@ -47,7 +132,7 @@ export function useWatchlistParamsPersistence({ handleParamsUpdate as EventListener ) } - }, [onWidgetParamsChange, panelId, widget?.key, params]) + }, [onWidgetParamsChange, panelId, widget?.key]) } interface EmitWatchlistParamsOptions { diff --git a/apps/tradinggoose/widgets/widget-surface.tsx b/apps/tradinggoose/widgets/widget-surface.tsx index 480ed336b..207543c33 100644 --- a/apps/tradinggoose/widgets/widget-surface.tsx +++ b/apps/tradinggoose/widgets/widget-surface.tsx @@ -6,6 +6,7 @@ import type { WidgetInstance } from '@/widgets/layout' import { isPairColor, type PairColor } from '@/widgets/pair-colors' import { getWidgetDefinition } from '@/widgets/registry' import type { WidgetComponentProps, WidgetHeaderSlots, WidgetRuntimeContext } from '@/widgets/types' +import type { LocaleCode } from '@/i18n/utils' import { PairColorDropdown } from '@/widgets/widgets/components/pair-color-dropdown' import { WidgetActionMenu } from '@/widgets/widgets/components/widget-action-menu' import { WidgetSelector } from '@/widgets/widgets/components/widget-selector' @@ -17,6 +18,7 @@ interface WidgetSurfaceProps { widget: WidgetInstance header?: WidgetSurfaceHeader context?: WidgetRuntimeContext + locale?: LocaleCode onPairColorChange?: (color: PairColor) => void onWidgetChange?: (widgetKey: string) => void panelId?: string @@ -30,6 +32,7 @@ function WidgetSurfaceComponent({ widget, header, context, + locale, onPairColorChange, onWidgetChange, panelId, @@ -136,6 +139,7 @@ function WidgetSurfaceComponent({ <RenderWidgetComponent params={widget?.params ?? null} context={context} + locale={locale} pairColor={pairColor} panelId={panelId} widget={widget} @@ -176,6 +180,7 @@ function arePropsEqual(prev: WidgetSurfaceProps, next: WidgetSurfaceProps) { sameWidget && prev.panelId === next.panelId && prev.context?.workspaceId === next.context?.workspaceId && + prev.locale === next.locale && prev.header === next.header && prev.onPairColorChange === next.onPairColorChange && prev.onWidgetChange === next.onWidgetChange && diff --git a/apps/tradinggoose/widgets/widgets/components/entity-editor-buttons.tsx b/apps/tradinggoose/widgets/widgets/components/entity-editor-buttons.tsx index 4c282986b..2561efc8a 100644 --- a/apps/tradinggoose/widgets/widgets/components/entity-editor-buttons.tsx +++ b/apps/tradinggoose/widgets/widgets/components/entity-editor-buttons.tsx @@ -2,7 +2,10 @@ import type { ComponentType } from 'react' import { Redo2, Undo2 } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useReviewSessionUndoRedoState } from '@/widgets/widgets/entity_review/review-session-controls' @@ -60,11 +63,13 @@ interface UndoRedoButtonProps { export function EntityEditorUndoButton({ reviewSessionId, onAction }: UndoRedoButtonProps) { const { canUndo } = useReviewSessionUndoRedoState(reviewSessionId) + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.entityEditor return ( <EntityEditorHeaderButton - tooltip='Undo' - label='Undo' + tooltip={copy.undo} + label={copy.undo} icon={Undo2} disabled={!canUndo} onClick={onAction} @@ -74,11 +79,13 @@ export function EntityEditorUndoButton({ reviewSessionId, onAction }: UndoRedoBu export function EntityEditorRedoButton({ reviewSessionId, onAction }: UndoRedoButtonProps) { const { canRedo } = useReviewSessionUndoRedoState(reviewSessionId) + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.entityEditor return ( <EntityEditorHeaderButton - tooltip='Redo' - label='Redo' + tooltip={copy.redo} + label={copy.redo} icon={Redo2} disabled={!canRedo} onClick={onAction} diff --git a/apps/tradinggoose/widgets/widgets/components/listing-selector.tsx b/apps/tradinggoose/widgets/widgets/components/listing-selector.tsx index eb8280d66..b08baec0a 100644 --- a/apps/tradinggoose/widgets/widgets/components/listing-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/components/listing-selector.tsx @@ -1,26 +1,31 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { createPortal } from 'react-dom' import { ChevronDown } from 'lucide-react' -import { cn } from '@/lib/utils' -import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' -import { formatDisplayText } from '@/components/ui/formatted-text' +import { createPortal } from 'react-dom' +import { + triggerCryptoRankUpdate, + triggerCurrencyRankUpdate, + triggerListingRankUpdate, +} from '@/components/listing-selector/listing/rank-updates' +import { requestListingResolution } from '@/components/listing-selector/selector/resolve-request' +import { useMarketListingSearch } from '@/components/listing-selector/selector/use-listing-search' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { useAccessibleReferencePrefixes } from '@/hooks/workflow/use-accessible-reference-prefixes' +import { formatDisplayText } from '@/components/ui/formatted-text' +import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import type { ListingIdentity, ListingOption } from '@/lib/listing/identity' -import { areListingIdentitiesEqual, toListingValue, toListingValueObject } from '@/lib/listing/identity' -import { requestListingResolution } from '@/components/listing-selector/selector/resolve-request' +import { + areListingIdentitiesEqual, + getListingIdentityKey, + toListingValue, + toListingValueObject, +} from '@/lib/listing/identity' +import { cn } from '@/lib/utils' +import { useAccessibleReferencePrefixes } from '@/hooks/workflow/use-accessible-reference-prefixes' import { createEmptyListingSelectorInstance, useListingSelectorStore, } from '@/stores/market/selector/store' -import { useMarketListingSearch } from '@/components/listing-selector/selector/use-listing-search' -import { - triggerCryptoRankUpdate, - triggerCurrencyRankUpdate, - triggerListingRankUpdate, -} from '@/components/listing-selector/listing/rank-updates' import { widgetHeaderControlClassName } from '@/widgets/widgets/components/widget-header-control' interface ListingSelectorProps { @@ -29,14 +34,12 @@ interface ListingSelectorProps { disabled?: boolean className?: string providerType?: 'market' | 'trading' + activateOnMount?: boolean onListingChange?: (listing: ListingOption | null) => void onListingValueChange?: (value: string | null) => void onListingTagSelect?: (value: string) => void } -const getListingIdentityKey = (listing: ListingIdentity) => - `${listing.listing_type}|${listing.listing_id}|${listing.base_id}|${listing.quote_id}` - const getListingOptionKey = (listing: ListingOption) => `${getListingIdentityKey(listing)}|${listing.base ?? ''}|${listing.quote ?? ''}|${listing.name ?? ''}` @@ -74,9 +77,7 @@ const hasListingDetails = (listing?: ListingOption | null): boolean => { return Boolean(quote) } -const getFlagData = ( - countryCode?: string | null -): { emoji: string; codepoints: string } | null => { +const getFlagData = (countryCode?: string | null): { emoji: string; codepoints: string } | null => { if (!countryCode) return null const code = countryCode.trim().toUpperCase() if (code.length !== 2) return null @@ -106,27 +107,30 @@ const ListingSelectorRow = ({ const companyName = listing ? getListingCompanyName(listing) : null const assetClassLabel = listing?.assetClass?.toUpperCase() ?? '' const flagData = getFlagData(listing?.countryCode) - const prefersFlagImage = - typeof navigator !== 'undefined' && /Windows/i.test(navigator.userAgent) + const prefersFlagImage = typeof navigator !== 'undefined' && /Windows/i.test(navigator.userAgent) const flagImageUrl = flagData ? `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${flagData.codepoints}.svg` : null return ( - <div className='flex min-w-0 flex-1 items-center gap-2 flex items-center'> + <div className='flex min-w-0 flex-1 items-center gap-2'> <Avatar className='h-4 w-4 rounded-xs bg-secondary'> {listing?.iconUrl ? <AvatarImage src={listing.iconUrl} alt={symbol} /> : null} - <AvatarFallback className='text-xs text-accent-foreground'> + <AvatarFallback className='text-accent-foreground text-xs'> {listing ? getListingFallback(listing) : '??'} </AvatarFallback> </Avatar> {showSecondary && companyName ? ( <div className='min-w-0 flex-1'> - <span className='block min-w-0 truncate text-sm font-medium'>{listing ? symbol : 'Select listing'}</span> - <span className='block min-w-0 truncate text-muted-foreground text-xs'>{companyName}</span> + <span className='block min-w-0 truncate font-medium text-sm'> + {listing ? symbol : 'Select listing'} + </span> + <span className='block min-w-0 truncate text-muted-foreground text-xs'> + {companyName} + </span> </div> ) : ( - <span className='min-w-0 truncate text-sm font-medium'> + <span className='min-w-0 truncate font-medium text-sm'> {listing ? symbol : 'Select listing'} </span> )} @@ -141,7 +145,7 @@ const ListingSelectorRow = ({ <span className='ml-1 text-xs'>{flagData.emoji}</span> ) : null} {assetClassLabel && listing ? ( - <span className='ml-auto p-1 text-xs font-semibold text-muted-foreground'> + <span className='ml-auto p-1 font-semibold text-muted-foreground text-xs'> {assetClassLabel} </span> ) : null} @@ -155,6 +159,7 @@ export function ListingSelector({ disabled, className, providerType = 'market', + activateOnMount = false, onListingChange, onListingValueChange, onListingTagSelect, @@ -169,14 +174,7 @@ export function ListingSelector({ }, [ensureInstance, instanceId]) const safeInstance = instance ?? createEmptyListingSelectorInstance() - const { - query, - results, - isLoading, - error, - selectedListing, - providerId, - } = safeInstance + const { query, results, isLoading, error, selectedListing, providerId } = safeInstance const [open, setOpen] = useState(false) const inputRef = useRef<HTMLInputElement>(null) @@ -192,6 +190,7 @@ export function ListingSelector({ } | null>(null) const hydratedListingRef = useRef<ListingIdentity | null>(null) const hydrateRequestRef = useRef(0) + const hasActivatedOnMountRef = useRef(false) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) const isVariableListingInput = useCallback((value: string) => { @@ -288,9 +287,7 @@ export function ListingSelector({ const lastOpen = value.lastIndexOf('<') const lastClose = value.indexOf('>', lastOpen + 1) const rawTag = - lastOpen >= 0 - ? value.slice(lastOpen + 1, lastClose >= 0 ? lastClose : value.length) - : value + lastOpen >= 0 ? value.slice(lastOpen + 1, lastClose >= 0 ? lastClose : value.length) : value const trimmedTag = rawTag.trim() const normalizedValue = trimmedTag ? `<${trimmedTag}>` : value commitVariableValue(normalizedValue, 'tag') @@ -309,8 +306,20 @@ export function ListingSelector({ }, [open]) useEffect(() => { - const selectedValue = - safeInstance.selectedListingValue ?? safeInstance.selectedListing ?? null + if (!activateOnMount || disabled || hasActivatedOnMountRef.current) return + hasActivatedOnMountRef.current = true + const nextQuery = query || selectedLabel + if (nextQuery && query !== nextQuery) { + updateInstance(instanceId, { query: nextQuery }) + } + setCursorPosition(nextQuery.length) + setShowTags(false) + setHighlightedIndex(-1) + setOpen(true) + }, [activateOnMount, disabled, instanceId, query, selectedLabel, updateInstance]) + + useEffect(() => { + const selectedValue = safeInstance.selectedListingValue ?? safeInstance.selectedListing ?? null if (!selectedValue) { hydratedListingRef.current = null return @@ -341,7 +350,7 @@ export function ListingSelector({ selectedListingValue: identity, }) }) - .catch(() => { }) + .catch(() => {}) return () => { cancelled = true @@ -399,7 +408,7 @@ export function ListingSelector({ const dropdown = showListingDropdown ? ( <div className={cn( - dropdownPosition ? 'absolute z-[1000]' : 'absolute left-0 top-full z-[200] mt-1 w-full' + dropdownPosition ? 'absolute z-[1000]' : 'absolute top-full left-0 z-[200] mt-1 w-full' )} style={ dropdownPosition @@ -411,6 +420,7 @@ export function ListingSelector({ : undefined } data-market-selector + data-market-selector-id={instanceId} onWheel={(event) => event.stopPropagation()} > <div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'> @@ -421,9 +431,9 @@ export function ListingSelector({ onTouchMove={(event) => event.stopPropagation()} > {isLoading ? ( - <div className='py-6 text-center text-sm text-muted-foreground'>Searching...</div> + <div className='py-6 text-center text-muted-foreground text-sm'>Searching...</div> ) : results.length === 0 ? ( - <div className='py-6 text-center text-sm text-muted-foreground'> + <div className='py-6 text-center text-muted-foreground text-sm'> {error || 'No listings found.'} </div> ) : ( @@ -454,14 +464,17 @@ export function ListingSelector({ ) : null return ( - <div ref={containerRef} className={cn('relative w-full', className)} data-market-selector> + <div + ref={containerRef} + className={cn('relative w-full', className)} + data-market-selector + data-market-selector-id={instanceId} + > <div className='relative'> <input ref={inputRef} className={cn( - widgetHeaderControlClassName( - 'w-full justify-center pr-9 text-sm font-medium' - ), + widgetHeaderControlClassName('w-full justify-center pr-9 font-medium text-sm'), hideInputText && 'text-transparent caret-transparent placeholder:text-transparent' )} name={`listing-search-${instanceId}`} @@ -594,17 +607,17 @@ export function ListingSelector({ disabled={disabled} /> {showRichOverlay ? ( - <div className='pointer-events-none absolute inset-y-0 left-0 flex items-center px-1 w-full'> + <div className='pointer-events-none absolute inset-y-0 left-0 flex w-full items-center px-1'> <ListingSelectorRow listing={selectedListing} /> </div> ) : null} {showPlaceholderOverlay ? ( - <div className='pointer-events-none absolute inset-y-0 left-0 flex items-center px-1 w-full'> + <div className='pointer-events-none absolute inset-y-0 left-0 flex w-full items-center px-1'> <ListingSelectorRow listing={null} /> </div> ) : null} {showTagOverlay ? ( - <div className='pointer-events-none absolute inset-y-0 left-0 flex items-center px-1 w-full'> + <div className='pointer-events-none absolute inset-y-0 left-0 flex w-full items-center px-1'> <div className='w-full truncate text-sm'> {formatDisplayText(query, { accessiblePrefixes, @@ -615,7 +628,7 @@ export function ListingSelector({ ) : null} <button type='button' - className='absolute right-1 top-1/2 z-10 h-6 w-6 -translate-y-1/2 p-0 bg-transparent' + className='-translate-y-1/2 absolute top-1/2 right-1 z-10 h-6 w-6 bg-transparent p-0' disabled={disabled} onMouseDown={(event) => { event.preventDefault() @@ -632,7 +645,12 @@ export function ListingSelector({ } }} > - <ChevronDown className={cn('h-4 w-4 opacity-0 transition-transform', open && 'rotate-180 opacity-50')} /> + <ChevronDown + className={cn( + 'h-4 w-4 opacity-0 transition-transform', + open && 'rotate-180 opacity-50' + )} + /> </button> </div> diff --git a/apps/tradinggoose/widgets/widgets/components/market-provider-controls.tsx b/apps/tradinggoose/widgets/widgets/components/market-provider-controls.tsx new file mode 100644 index 000000000..1cdf54fc3 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/market-provider-controls.tsx @@ -0,0 +1,64 @@ +'use client' + +import { useMemo } from 'react' +import { cn } from '@/lib/utils' +import { + type MarketProviderOption, + MarketProviderSelector, +} from '@/widgets/widgets/components/market-provider-selector' +import { + MarketProviderSettingsButton, + type MarketProviderSettingsSaveResult, +} from '@/widgets/widgets/components/market-provider-settings-button' +import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' + +type MarketProviderControlsProps = { + value?: string | null + options: MarketProviderOption[] + onChange?: (providerId: string) => void + disabled?: boolean + placeholder?: string + providerParams?: Record<string, unknown> + authParams?: Record<string, unknown> + workspaceId?: string + onSettingsSave: (next: MarketProviderSettingsSaveResult) => void + className?: string +} + +export function MarketProviderControls({ + value, + options, + onChange, + disabled = false, + placeholder, + providerParams, + authParams, + workspaceId, + onSettingsSave, + className, +}: MarketProviderControlsProps) { + const selectedProvider = useMemo( + () => options.find((option) => option.id === value), + [options, value] + ) + + return ( + <div className={widgetHeaderButtonGroupClassName(cn('min-w-0', className))}> + <MarketProviderSelector + value={value} + options={options} + onChange={onChange} + disabled={disabled} + placeholder={placeholder} + /> + <MarketProviderSettingsButton + providerId={value} + providerName={selectedProvider?.name} + providerParams={providerParams} + authParams={authParams} + workspaceId={workspaceId} + onSave={onSettingsSave} + /> + </div> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/components/market-provider-selector.test.tsx b/apps/tradinggoose/widgets/widgets/components/market-provider-selector.test.tsx new file mode 100644 index 000000000..a99f7f8d1 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/market-provider-selector.test.tsx @@ -0,0 +1,66 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { MarketProviderSelector } from '@/widgets/widgets/components/market-provider-selector' + +describe('MarketProviderSelector', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('renders the selected market provider name instead of an icon-only trigger', () => { + act(() => { + root.render( + <TooltipProvider> + <MarketProviderSelector + value='alpaca' + options={[ + { id: 'alpaca', name: 'Alpaca' }, + { id: 'yahoo-finance', name: 'Yahoo Finance' }, + ]} + /> + </TooltipProvider> + ) + }) + + const button = container.querySelector('button[aria-label="Select market provider"]') + expect(button?.textContent).toContain('Market: Alpaca') + }) + + it('renders a clear placeholder before a market provider is selected', () => { + act(() => { + root.render( + <TooltipProvider> + <MarketProviderSelector + value='' + options={[{ id: 'alpaca', name: 'Alpaca' }]} + placeholder='Select market data' + /> + </TooltipProvider> + ) + }) + + const button = container.querySelector('button[aria-label="Select market provider"]') + expect(button?.textContent).toContain('Select market data') + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/components/market-provider-selector.tsx b/apps/tradinggoose/widgets/widgets/components/market-provider-selector.tsx index 9e6d519c9..38fd79bd5 100644 --- a/apps/tradinggoose/widgets/widgets/components/market-provider-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/components/market-provider-selector.tsx @@ -2,7 +2,7 @@ import type { ComponentType } from 'react' import { useMemo } from 'react' -import { Check } from 'lucide-react' +import { Check, ChevronDown } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -18,7 +18,7 @@ import { widgetHeaderMenuTextClassName, } from '@/widgets/widgets/components/widget-header-control' -type ProviderOption = { +export type MarketProviderOption = { id: string name: string icon?: ComponentType<{ className?: string }> @@ -26,7 +26,7 @@ type ProviderOption = { interface MarketProviderSelectorProps { value?: string | null - options: ProviderOption[] + options: MarketProviderOption[] onChange?: (providerId: string) => void disabled?: boolean placeholder?: string @@ -34,7 +34,7 @@ interface MarketProviderSelectorProps { menuClassName?: string } -const DEFAULT_PLACEHOLDER = 'Select provider' +const DEFAULT_PLACEHOLDER = 'Select market' export function MarketProviderSelector({ value, @@ -47,10 +47,12 @@ export function MarketProviderSelector({ }: MarketProviderSelectorProps) { const selected = useMemo(() => options.find((option) => option.id === value), [options, value]) - const label = selected?.name ?? placeholder + const label = selected ? `Market: ${selected.name}` : placeholder const SelectedIcon = selected?.icon const isDropdownDisabled = disabled || options.length === 0 - const tooltipText = isDropdownDisabled ? 'Provider selection unavailable' : 'Select provider' + const tooltipText = isDropdownDisabled + ? 'Provider selection unavailable' + : 'Select market data provider' return ( <DropdownMenu modal={false}> @@ -62,18 +64,31 @@ export function MarketProviderSelector({ type='button' disabled={isDropdownDisabled} className={widgetHeaderControlClassName( - cn('flex w-7 items-center justify-center px-0', triggerClassName) + cn('group flex justify-between', triggerClassName) )} aria-haspopup='listbox' + aria-label='Select market provider' > - {SelectedIcon ? ( - <SelectedIcon className='h-4 w-4 text-muted-foreground' aria-hidden='true' /> - ) : ( - <span className='text-xs font-semibold text-muted-foreground'> - {label.slice(0, 1)} + <span className='flex min-w-0 items-center gap-1.5'> + {SelectedIcon ? ( + <SelectedIcon + className='h-4 w-4 shrink-0 text-muted-foreground' + aria-hidden='true' + /> + ) : null} + <span + className={cn( + 'min-w-0 text-left', + selected ? 'text-foreground' : 'text-muted-foreground' + )} + > + {label} </span> - )} - <span className='sr-only'>{label}</span> + </span> + <ChevronDown + className='h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180' + aria-hidden='true' + /> </button> </DropdownMenuTrigger> </span> @@ -85,7 +100,7 @@ export function MarketProviderSelector({ className={cn(widgetHeaderMenuContentClassName, 'w-[220px]', menuClassName)} > {options.length === 0 ? ( - <div className='px-2 py-2 text-xs text-muted-foreground'>No providers</div> + <div className='px-2 py-2 text-muted-foreground text-xs'>No providers</div> ) : ( options.map((option) => { const isSelected = option.id === value diff --git a/apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.test.tsx b/apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.test.tsx new file mode 100644 index 000000000..bcdbf46d4 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.test.tsx @@ -0,0 +1,182 @@ +/** + * @vitest-environment jsdom + */ + +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MarketProviderSettingsButton } from '@/widgets/widgets/components/market-provider-settings-button' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children?: ReactNode }) => <>{children}</>, + TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>, + TooltipContent: ({ children }: { children?: ReactNode }) => <>{children}</>, +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children?: ReactNode }) => <>{children}</>, + PopoverTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>, + PopoverContent: ({ children }: { children?: ReactNode }) => <>{children}</>, +})) + +vi.mock('@/components/ui/select', () => ({ + Select: ({ children }: { children?: ReactNode }) => <>{children}</>, + SelectTrigger: ({ children, id }: { children?: ReactNode; id?: string }) => ( + <button type='button' id={id}> + {children} + </button> + ), + SelectValue: ({ placeholder }: { placeholder?: string }) => <span>{placeholder}</span>, + SelectContent: ({ children }: { children?: ReactNode }) => <>{children}</>, + SelectItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>, +})) + +vi.mock('@/components/ui/env-var-dropdown', () => ({ + checkEnvVarTrigger: () => ({ show: false, searchTerm: '' }), + EnvVarDropdown: () => null, +})) + +describe('MarketProviderSettingsButton', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('saves raw credential values', async () => { + const onSave = vi.fn() + + await act(async () => { + root.render( + <MarketProviderSettingsButton providerId='alpaca' providerName='Alpaca' onSave={onSave} /> + ) + }) + + expect(container.textContent).toContain('Alpaca config') + + const apiKeyInput = container.querySelector( + '#market-provider-param-alpaca-apiKey' + ) as HTMLInputElement | null + expect(apiKeyInput).toBeTruthy() + + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(apiKeyInput, 'raw-key') + await act(async () => { + apiKeyInput?.dispatchEvent(new Event('input', { bubbles: true })) + }) + + const saveButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save' + ) + expect(saveButton).toBeTruthy() + + await act(async () => { + saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onSave).toHaveBeenCalledWith({ + auth: { + apiKey: 'raw-key', + }, + providerParams: undefined, + }) + }) + + it('renders and resaves raw persisted credentials', async () => { + const onSave = vi.fn() + + await act(async () => { + root.render( + <MarketProviderSettingsButton + providerId='alpaca' + providerName='Alpaca' + authParams={{ + apiKey: 'raw-key', + apiSecret: 'raw-secret', + }} + onSave={onSave} + /> + ) + }) + + const apiKeyInput = container.querySelector( + '#market-provider-param-alpaca-apiKey' + ) as HTMLInputElement | null + const apiSecretInput = container.querySelector( + '#market-provider-param-alpaca-apiSecret' + ) as HTMLInputElement | null + + expect(apiKeyInput?.value).toBe('raw-key') + expect(apiSecretInput?.value).toBe('raw-secret') + + const saveButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save' + ) + + await act(async () => { + saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onSave).toHaveBeenCalledWith({ + auth: { + apiKey: 'raw-key', + apiSecret: 'raw-secret', + }, + providerParams: undefined, + }) + }) + + it('clears an existing env credential when the input is emptied', async () => { + const onSave = vi.fn() + + await act(async () => { + root.render( + <MarketProviderSettingsButton + providerId='alpaca' + providerName='Alpaca' + authParams={{ + apiKey: '{{ ALPACA_API_KEY }}', + }} + onSave={onSave} + /> + ) + }) + + const apiKeyInput = container.querySelector( + '#market-provider-param-alpaca-apiKey' + ) as HTMLInputElement | null + expect(apiKeyInput?.value).toBe('{{ ALPACA_API_KEY }}') + + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(apiKeyInput, '') + await act(async () => { + apiKeyInput?.dispatchEvent(new Event('input', { bubbles: true })) + }) + + const saveButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save' + ) + await act(async () => { + saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onSave).toHaveBeenCalledWith({ + auth: undefined, + providerParams: undefined, + }) + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.tsx b/apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.tsx new file mode 100644 index 000000000..e0ec7db42 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.tsx @@ -0,0 +1,343 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { KeyRound } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + isMarketProviderCredentialDefinition, + resolveMarketProviderSettingsDefinitions, + sanitizeMarketProviderAuth, + sanitizeMarketProviderParamsForWidget, +} from '@/lib/market/market-provider-settings' +import { cn } from '@/lib/utils' +import type { MarketProviderParamDefinition } from '@/providers/market/providers' +import { widgetHeaderControlClassName } from '@/widgets/widgets/components/widget-header-control' + +export type MarketProviderSettingsSaveResult = { + auth?: Record<string, unknown> + providerParams?: Record<string, unknown> +} + +type MarketProviderSettingsButtonProps = { + providerId?: string | null + providerName?: string + providerParams?: Record<string, unknown> + authParams?: Record<string, unknown> + workspaceId?: string + onSave: (next: MarketProviderSettingsSaveResult) => void +} + +const resolveSavedValue = ({ + definition, + authParams, + providerParams, +}: { + definition: MarketProviderParamDefinition + authParams?: Record<string, unknown> + providerParams?: Record<string, unknown> +}) => { + if (definition.id === 'apiKey' || definition.id === 'apiSecret') { + return authParams?.[definition.id] + } + + return providerParams?.[definition.id] +} + +type MarketProviderTextInputProps = { + id: string + definition: MarketProviderParamDefinition + value: string + isCredential: boolean + workspaceId?: string + onChange: (value: string) => void +} + +function MarketProviderTextInput({ + id, + definition, + value, + isCredential, + workspaceId, + onChange, +}: MarketProviderTextInputProps) { + const inputRef = useRef<HTMLInputElement>(null) + const [showEnvVars, setShowEnvVars] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [cursorPosition, setCursorPosition] = useState(0) + + const updateEnvPicker = (nextValue: string, nextCursorPosition: number, focused: boolean) => { + if (!focused) { + setShowEnvVars(false) + setSearchTerm('') + return + } + + const envVarTrigger = checkEnvVarTrigger(nextValue, nextCursorPosition) + const shouldShowCredentialPicker = + isCredential && (nextValue.trim() === '' || envVarTrigger.show) + + setShowEnvVars(shouldShowCredentialPicker || envVarTrigger.show) + setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '') + } + + return ( + <div className='relative'> + <Input + ref={inputRef} + id={id} + type={isCredential ? 'password' : definition.type === 'number' ? 'number' : 'text'} + value={value} + onChange={(event) => { + const nextValue = event.target.value + const nextCursorPosition = event.target.selectionStart ?? nextValue.length + setCursorPosition(nextCursorPosition) + onChange(nextValue) + updateEnvPicker(nextValue, nextCursorPosition, true) + }} + onFocus={(event) => { + const nextCursorPosition = event.currentTarget.selectionStart ?? value.length + setCursorPosition(nextCursorPosition) + updateEnvPicker(value, nextCursorPosition, true) + }} + onBlur={() => { + setShowEnvVars(false) + setSearchTerm('') + }} + placeholder={definition.placeholder} + min={definition.min} + max={definition.max} + step={definition.step} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck={false} + /> + {isCredential ? ( + <EnvVarDropdown + visible={showEnvVars} + onSelect={(nextValue) => { + onChange(nextValue) + setShowEnvVars(false) + setSearchTerm('') + requestAnimationFrame(() => inputRef.current?.focus()) + }} + searchTerm={searchTerm} + inputValue={value} + cursorPosition={cursorPosition} + workspaceId={workspaceId} + onClose={() => { + setShowEnvVars(false) + setSearchTerm('') + }} + /> + ) : null} + </div> + ) +} + +export function MarketProviderSettingsButton({ + providerId, + providerName, + providerParams, + authParams, + workspaceId, + onSave, +}: MarketProviderSettingsButtonProps) { + const trimmedProviderId = typeof providerId === 'string' ? providerId.trim() : '' + const definitions = resolveMarketProviderSettingsDefinitions(trimmedProviderId) + const [settingsOpen, setSettingsOpen] = useState(false) + const paramValuesRef = useRef<Record<string, unknown>>({}) + const changedParamIdsRef = useRef<Set<string>>(new Set()) + const [inputValues, setInputValues] = useState<Record<string, string>>({}) + + useEffect(() => { + if (!settingsOpen) return + paramValuesRef.current = {} + changedParamIdsRef.current = new Set() + setInputValues({}) + }, [settingsOpen]) + + if (definitions.length === 0) return null + + const resolvedProviderName = providerName?.trim() || 'Market' + const triggerLabel = `${resolvedProviderName} config` + + const handleParamChange = (id: string, value: unknown) => { + changedParamIdsRef.current.add(id) + if (typeof value === 'string' && value.trim() === '') { + delete paramValuesRef.current[id] + return + } + paramValuesRef.current[id] = value + } + + const handleSave = () => { + if (!trimmedProviderId) return + + const nextProviderParamsInput = { + ...(providerParams ?? {}), + ...paramValuesRef.current, + } + const resolveNextCredentialValue = (id: 'apiKey' | 'apiSecret') => + changedParamIdsRef.current.has(id) ? paramValuesRef.current[id] : authParams?.[id] + + const nextAuthInput = { + ...(authParams ?? {}), + apiKey: resolveNextCredentialValue('apiKey'), + apiSecret: resolveNextCredentialValue('apiSecret'), + } + + onSave({ + providerParams: sanitizeMarketProviderParamsForWidget( + trimmedProviderId, + nextProviderParamsInput + ), + auth: sanitizeMarketProviderAuth(nextAuthInput), + }) + setSettingsOpen(false) + } + + return ( + <Popover open={settingsOpen} onOpenChange={setSettingsOpen}> + <Tooltip> + <TooltipTrigger asChild> + <PopoverTrigger asChild> + <button + type='button' + className={widgetHeaderControlClassName( + cn('flex justify-between gap-1.5') + )} + disabled={!trimmedProviderId} + aria-label={`Configure ${resolvedProviderName} provider`} + > + <KeyRound className='h-3.5 w-3.5 shrink-0 text-muted-foreground' /> + <span className='min-w-0 text-left'>{triggerLabel}</span> + </button> + </PopoverTrigger> + </TooltipTrigger> + <TooltipContent side='top'>{triggerLabel}</TooltipContent> + </Tooltip> + <PopoverContent className='w-72 space-y-3 p-4'> + <div className='space-y-1'> + <p className='font-medium text-sm'>Provider settings</p> + <p className='text-muted-foreground text-xs'>Save credentials for this widget.</p> + </div> + <div className='space-y-3'> + {definitions.map((definition) => { + const inputId = `market-provider-param-${trimmedProviderId}-${definition.id}` + const isCredential = isMarketProviderCredentialDefinition(definition) + const resolvedValue = + resolveSavedValue({ + definition, + authParams, + providerParams, + }) ?? (isCredential ? undefined : definition.defaultValue) + const selectValue = + typeof resolvedValue === 'string' || typeof resolvedValue === 'number' + ? String(resolvedValue) + : undefined + const inputValue = + typeof resolvedValue === 'string' || typeof resolvedValue === 'number' + ? String(resolvedValue) + : typeof resolvedValue === 'object' && resolvedValue !== null + ? JSON.stringify(resolvedValue) + : undefined + const booleanValue = + typeof resolvedValue === 'boolean' + ? resolvedValue + : typeof resolvedValue === 'string' + ? resolvedValue.toLowerCase() === 'true' + : false + const controlledValue = inputValues[definition.id] ?? inputValue ?? '' + + if (definition.inputType === 'switch' || definition.type === 'boolean') { + return ( + <div + key={`${trimmedProviderId}-${definition.id}`} + className='flex items-center justify-between gap-2' + > + <Label htmlFor={inputId} className='text-xs'> + {definition.title ?? definition.id} + </Label> + <Switch + id={inputId} + defaultChecked={booleanValue} + onCheckedChange={(checked) => handleParamChange(definition.id, checked)} + /> + </div> + ) + } + + if (definition.options?.length) { + return ( + <div key={`${trimmedProviderId}-${definition.id}`} className='space-y-1'> + <Label htmlFor={inputId} className='text-xs'> + {definition.title ?? definition.id} + </Label> + <Select + defaultValue={selectValue} + onValueChange={(nextValue) => handleParamChange(definition.id, nextValue)} + > + <SelectTrigger id={inputId}> + <SelectValue placeholder={definition.placeholder ?? 'Select'} /> + </SelectTrigger> + <SelectContent> + {definition.options.map((option) => ( + <SelectItem key={option.id} value={option.id}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + ) + } + + return ( + <div key={`${trimmedProviderId}-${definition.id}`} className='space-y-1'> + <Label htmlFor={inputId} className='text-xs'> + {definition.title ?? definition.id} + </Label> + <MarketProviderTextInput + id={inputId} + definition={definition} + isCredential={isCredential} + value={controlledValue} + onChange={(value) => { + setInputValues((current) => ({ + ...current, + [definition.id]: value, + })) + handleParamChange(definition.id, value) + }} + workspaceId={workspaceId} + /> + </div> + ) + })} + </div> + <div className='flex justify-end gap-2'> + <Button size='sm' variant='outline' onClick={() => setSettingsOpen(false)}> + Cancel + </Button> + <Button size='sm' onClick={handleSave}> + Save + </Button> + </div> + </PopoverContent> + </Popover> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx index 8dee30a7c..d8ffa1cfa 100644 --- a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx @@ -12,6 +12,9 @@ import { import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useLocale } from 'next-intl' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import { useMcpServersStore } from '@/stores/mcp-servers/store' import type { McpServerWithStatus } from '@/stores/mcp-servers/types' @@ -22,7 +25,6 @@ import { widgetHeaderMenuTextClassName, } from '@/widgets/widgets/components/widget-header-control' -const DEFAULT_PLACEHOLDER = 'Select MCP server' const DROPDOWN_MAX_HEIGHT = '20rem' const DROPDOWN_VIEWPORT_HEIGHT = '14rem' @@ -48,18 +50,20 @@ const getServerIconColor = (status?: McpServerWithStatus['connectionStatus']) => return '#64748b' } -const getServerLabel = (server?: McpServerWithStatus | null) => - server?.name || server?.id || 'Unnamed server' +const getServerLabel = (server?: McpServerWithStatus | null, fallbackLabel?: string) => + server?.name || server?.id || fallbackLabel || '' export function McpDropdown({ workspaceId, value, onChange, disabled = false, - placeholder = DEFAULT_PLACEHOLDER, + placeholder, align = 'start', triggerClassName, }: McpDropdownProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.mcpDropdown const [searchQuery, setSearchQuery] = useState('') const { servers, isLoading, error, fetchServers } = useMcpServersStore( (state) => ({ @@ -88,12 +92,13 @@ export function McpDropdown({ const hasServers = workspaceServers.length > 0 const isDropdownDisabled = disabled || !workspaceId const tooltipText = !workspaceId - ? 'Select a workspace to choose MCP servers' + ? copy.selectWorkspaceFirst : error - ? 'Unable to load MCP servers' + ? copy.unableToLoad : disabled - ? 'MCP selection unavailable' - : 'Select MCP server' + ? copy.mcpSelectionUnavailable + : copy.selectMcpServer + const resolvedPlaceholder = placeholder ?? copy.selectMcpServer useEffect(() => { setSearchQuery('') @@ -150,7 +155,7 @@ export function McpDropdown({ if (!workspaceId) { return ( <p className='px-2 py-4 text-center text-muted-foreground text-xs'> - Select a workspace first. + {copy.selectWorkspaceFirst} </p> ) } @@ -158,13 +163,13 @@ export function McpDropdown({ if (error && !hasServers) { return ( <div className='space-y-2 px-3 py-2 text-xs'> - <p className='text-destructive'>Unable to load MCP servers.</p> + <p className='text-destructive'>{copy.unableToLoad}</p> <button type='button' className='font-semibold text-primary text-xs hover:underline' onClick={handleRetry} > - Retry + {copy.retry} </button> </div> ) @@ -174,7 +179,7 @@ export function McpDropdown({ return ( <div className='flex items-center gap-1 px-3 py-2 text-muted-foreground text-xs'> <Loader2 className='h-3.5 w-3.5 animate-spin' /> - Loading MCP servers... + {copy.loading} </div> ) } @@ -182,7 +187,7 @@ export function McpDropdown({ if (!hasServers) { return ( <p className='px-2 py-4 text-center text-muted-foreground text-xs'> - No MCP servers available yet. + {copy.noServersAvailable} </p> ) } @@ -190,7 +195,7 @@ export function McpDropdown({ if (filteredServers.length === 0) { return ( <p className='px-2 py-4 text-center text-muted-foreground text-xs'> - {searchQuery.trim() ? 'No servers found.' : 'No MCP servers available yet.'} + {searchQuery.trim() ? copy.noServersFound : copy.noServersAvailable} </p> ) } @@ -224,7 +229,7 @@ export function McpDropdown({ /> </span> <span className={cn(widgetHeaderMenuTextClassName, 'truncate')}> - {getServerLabel(server)} + {getServerLabel(server, copy.unnamedServer)} </span> </div> {isSelected ? <Check className='h-3.5 w-3.5 text-primary' /> : null} @@ -249,11 +254,11 @@ export function McpDropdown({ ) const labelContent = selectedServer ? ( <span className='min-w-0 flex-1 truncate text-left font-medium text-foreground text-sm'> - {getServerLabel(selectedServer)} + {getServerLabel(selectedServer, copy.unnamedServer)} </span> ) : ( <span className='min-w-0 flex-1 truncate text-left font-medium text-muted-foreground text-sm'> - {placeholder} + {resolvedPlaceholder} </span> ) @@ -306,7 +311,7 @@ export function McpDropdown({ value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} onKeyDown={handleSearchInputKeyDown} - placeholder='Search servers...' + 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' autoComplete='off' autoCorrect='off' diff --git a/apps/tradinggoose/widgets/widgets/components/pair-color-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/pair-color-dropdown.tsx index ec56f91f3..9cfeadfca 100644 --- a/apps/tradinggoose/widgets/widgets/components/pair-color-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/pair-color-dropdown.tsx @@ -7,6 +7,9 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useLocale } from 'next-intl' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import { PAIR_COLOR_META, PAIR_COLOR_OPTIONS, type PairColor } from '@/widgets/pair-colors' import { @@ -22,10 +25,12 @@ interface PairColorDropdownProps { } export function PairColorDropdown({ color, onChange }: PairColorDropdownProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.pairColor const meta = PAIR_COLOR_META[color] const disabled = !onChange - const tooltipText = disabled ? 'Color selection unavailable' : 'Select widget color' + const tooltipText = disabled ? copy.selectionUnavailable : copy.selectWidgetColor return ( <DropdownMenu modal={false}> @@ -74,7 +79,14 @@ export function PairColorDropdown({ color, onChange }: PairColorDropdownProps) { }} aria-hidden /> - <span className={widgetHeaderMenuTextClassName}>{option.label}</span> + {(() => { + const labelKey = option.value === 'gray' ? 'unlinked' : option.value + return ( + <span className={widgetHeaderMenuTextClassName}> + {copy[labelKey as keyof typeof copy] ?? option.label} + </span> + ) + })()} </span> </DropdownMenuItem> ))} diff --git a/apps/tradinggoose/widgets/widgets/components/trading-account-selector.test.tsx b/apps/tradinggoose/widgets/widgets/components/trading-account-selector.test.tsx new file mode 100644 index 000000000..5e63e8d72 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/trading-account-selector.test.tsx @@ -0,0 +1,180 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { TradingAccountSelector } from '@/widgets/widgets/components/trading-account-selector' + +const mockUseOAuthCredentials = vi.fn() +const mockUseTradingAccounts = vi.fn() + +vi.mock('@/hooks/queries/oauth-credentials', () => ({ + useOAuthCredentialsByProviderIds: (...args: unknown[]) => mockUseOAuthCredentials(...args), +})) + +vi.mock('@/hooks/queries/trading-portfolio', () => ({ + useTradingAccounts: (...args: unknown[]) => mockUseTradingAccounts(...args), +})) + +describe('TradingAccountSelector', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + mockUseOAuthCredentials.mockReturnValue({ + data: { + 'alpaca-live': [{ id: 'cred-1', name: 'Primary Broker', provider: 'alpaca-live' }], + }, + isLoading: false, + error: null, + refetch: vi.fn(), + }) + mockUseTradingAccounts.mockReturnValue({ + data: [ + { + id: 'acct-1', + name: 'Alpaca Account', + type: 'cash', + status: 'active', + baseCurrency: 'USD', + }, + { + id: 'acct-2', + name: 'Live Account', + type: 'margin', + status: 'active', + baseCurrency: 'USD', + }, + ], + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('renders the selected broker account from the shared provider connection and account id', () => { + act(() => { + root.render( + <TooltipProvider> + <TradingAccountSelector + workspaceId='workspace-1' + providerId='alpaca' + accountId='acct-1' + /> + </TooltipProvider> + ) + }) + + const button = container.querySelector('button[aria-label="Select trading account"]') + expect(button?.textContent).toContain('Alpaca Account') + expect(mockUseOAuthCredentials).toHaveBeenCalledWith(['alpaca-live', 'alpaca-paper'], true) + expect(mockUseTradingAccounts).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + enabled: true, + }) + }) + + it('renders normalized account metadata in account menu descriptions', () => { + act(() => { + root.render( + <TooltipProvider> + <TradingAccountSelector + workspaceId='workspace-1' + providerId='alpaca' + accountId='acct-1' + /> + </TooltipProvider> + ) + }) + + const button = container.querySelector<HTMLButtonElement>( + 'button[aria-label="Select trading account"]' + ) + act(() => { + button?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) + }) + + expect(document.body.textContent).toContain('cash - active - USD') + expect(document.body.textContent).not.toContain('unknown - active - USD') + }) + + it('renders placeholder text before a provider is selected', () => { + act(() => { + root.render( + <TooltipProvider> + <TradingAccountSelector placeholder='Select account' /> + </TooltipProvider> + ) + }) + + const button = container.querySelector('button[aria-label="Select trading account"]') + expect(button?.textContent).toContain('Select account') + expect((button as HTMLButtonElement | null)?.disabled).toBe(true) + }) + + it('shows loading text instead of an unresolved account id while accounts load', () => { + mockUseTradingAccounts.mockReturnValue({ + data: [], + isLoading: true, + isFetching: true, + error: null, + refetch: vi.fn(), + }) + + act(() => { + root.render( + <TooltipProvider> + <TradingAccountSelector + workspaceId='workspace-1' + providerId='alpaca' + accountId='8b594a8c-1353-40d0-981c-e022a879e0e0' + /> + </TooltipProvider> + ) + }) + + const button = container.querySelector('button[aria-label="Select trading account"]') + expect(button?.textContent).toContain('Loading account...') + expect(button?.textContent).not.toContain('8b594a8c-1353-40d0-981c-e022a879e0e0') + }) + + it('shows placeholder text instead of a stale account id after accounts load', () => { + act(() => { + root.render( + <TooltipProvider> + <TradingAccountSelector + workspaceId='workspace-1' + providerId='alpaca' + accountId='stale-account-id' + placeholder='Select account' + /> + </TooltipProvider> + ) + }) + + const button = container.querySelector('button[aria-label="Select trading account"]') + expect(button?.textContent).toContain('Select account') + expect(button?.textContent).not.toContain('stale-account-id') + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/components/trading-account-selector.tsx b/apps/tradinggoose/widgets/widgets/components/trading-account-selector.tsx new file mode 100644 index 000000000..00ed65d6d --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/trading-account-selector.tsx @@ -0,0 +1,271 @@ +'use client' + +import { useState } from 'react' +import { Check, ChevronDown, Plus, RefreshCw } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { useTradingAccounts } from '@/hooks/queries/trading-portfolio' +import { getTradingProviderDefinition } from '@/providers/trading/providers' +import type { UnifiedTradingAccount } from '@/providers/trading/types' +import { + getTradingCredentialServiceName, + useTradingCredentialServices, +} from '@/widgets/widgets/components/trading-credential-services' +import { resolveTradingProviderIcon } from '@/widgets/widgets/components/trading-provider-selector' +import { + widgetHeaderControlClassName, + widgetHeaderMenuContentClassName, + widgetHeaderMenuItemClassName, +} from '@/widgets/widgets/components/widget-header-control' +import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' + +export type TradingAccountSelection = { + accountId?: string | null + credentialServiceId?: string | null +} + +type TradingAccountSelectorProps = { + workspaceId?: string | null + providerId?: string | null + credentialServiceId?: string | null + accountId?: string | null + disabled?: boolean + placeholder?: string + tooltipText?: string + toolName?: string + onAccountSelect?: (selection: TradingAccountSelection) => void +} + +const getAccountName = (account: UnifiedTradingAccount) => account.name ?? account.id + +const getAccountDescriptionPart = (value?: string | null) => { + const trimmed = typeof value === 'string' ? value.trim() : '' + return trimmed && trimmed !== 'unknown' ? trimmed : null +} + +const getAccountDescription = (account: UnifiedTradingAccount) => + [account.type, account.status, account.baseCurrency] + .map(getAccountDescriptionPart) + .filter(Boolean) + .join(' - ') + +export function TradingAccountSelector({ + workspaceId, + providerId, + credentialServiceId, + accountId, + disabled = false, + placeholder = 'Select account', + tooltipText = 'Select trading account', + toolName = 'Trading', + onAccountSelect, +}: TradingAccountSelectorProps) { + const [showOAuthModal, setShowOAuthModal] = useState(false) + const [oauthModalServiceId, setOAuthModalServiceId] = useState<string | null>(null) + const trimmedWorkspaceId = typeof workspaceId === 'string' ? workspaceId.trim() : '' + const trimmedProviderId = typeof providerId === 'string' ? providerId.trim() : '' + const providerDefinition = trimmedProviderId + ? getTradingProviderDefinition(trimmedProviderId) + : undefined + const providerName = providerDefinition?.name ?? 'broker' + const oauthProvider = providerDefinition?.oauth?.provider + const isEnabled = Boolean(trimmedWorkspaceId && trimmedProviderId) && !disabled + const credentialServices = useTradingCredentialServices({ + providerId: trimmedProviderId, + credentialServiceId, + enabled: isEnabled, + }) + const activeServiceId = credentialServices.activeServiceId + const hasConnection = + Boolean(activeServiceId) && credentialServices.connectedServiceIds.includes(activeServiceId!) + const accountsQuery = useTradingAccounts({ + workspaceId: trimmedWorkspaceId || undefined, + provider: trimmedProviderId || undefined, + credentialServiceId: activeServiceId, + enabled: isEnabled && hasConnection, + }) + const accounts = accountsQuery.data ?? [] + const selectedAccountId = + typeof accountId === 'string' && accountId.trim() ? accountId.trim() : '' + const selectedOption = accounts.find((account) => account.id === selectedAccountId) ?? null + const isLoadingAccounts = + credentialServices.isLoading || accountsQuery.isLoading || accountsQuery.isFetching + const hasUnresolvedSelectedAccount = Boolean(selectedAccountId && !selectedOption) + const buttonLabel = selectedOption + ? getAccountName(selectedOption) + : hasUnresolvedSelectedAccount && isLoadingAccounts + ? 'Loading account...' + : placeholder + const ProviderIcon = resolveTradingProviderIcon(trimmedProviderId) + + const handleOAuthClose = () => { + setShowOAuthModal(false) + credentialServices.refetch() + void accountsQuery.refetch() + } + + const openOAuthModal = (serviceId: string) => { + setOAuthModalServiceId(serviceId) + setShowOAuthModal(true) + } + + return ( + <> + <DropdownMenu modal={false}> + <Tooltip> + <TooltipTrigger asChild> + <span className='inline-flex'> + <DropdownMenuTrigger asChild> + <button + type='button' + disabled={!trimmedProviderId || disabled} + className={widgetHeaderControlClassName('group flex justify-between gap-2')} + aria-haspopup='listbox' + aria-label='Select trading account' + > + <span className='flex min-w-0 items-center gap-1.5'> + {ProviderIcon ? ( + <ProviderIcon + className='h-4 w-4 shrink-0 text-muted-foreground' + aria-hidden='true' + /> + ) : null} + <span + className={cn( + 'min-w-0 truncate text-left', + selectedOption ? 'font-medium text-foreground' : 'text-muted-foreground' + )} + > + {buttonLabel} + </span> + </span> + <ChevronDown + className='h-4 w-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180' + aria-hidden='true' + /> + </button> + </DropdownMenuTrigger> + </span> + </TooltipTrigger> + <TooltipContent side='top'>{tooltipText}</TooltipContent> + </Tooltip> + <DropdownMenuContent + sideOffset={6} + className={cn(widgetHeaderMenuContentClassName, 'w-[300px] p-1')} + > + {credentialServices.isLoading ? ( + <div className='flex items-center gap-2 px-3 py-2 text-muted-foreground text-xs'> + <RefreshCw className='h-3.5 w-3.5 animate-spin' /> + Loading provider connection... + </div> + ) : credentialServices.error ? ( + <div className='px-3 py-2 text-muted-foreground text-xs'> + Unable to load provider connection. + </div> + ) : credentialServices.serviceIds.length > 1 && !activeServiceId ? ( + <> + <div className='px-3 py-2 text-muted-foreground text-xs'> + Select a {providerName} connection. + </div> + {credentialServices.connectedServiceIds.map((serviceId) => ( + <DropdownMenuItem + key={serviceId} + className={cn(widgetHeaderMenuItemClassName, 'items-center justify-between')} + onSelect={() => { + onAccountSelect?.({ accountId: null, credentialServiceId: serviceId }) + }} + > + <span className='truncate text-foreground'> + {getTradingCredentialServiceName(trimmedProviderId, serviceId)} + </span> + </DropdownMenuItem> + ))} + </> + ) : !hasConnection ? ( + <div className='px-3 py-2 text-muted-foreground text-xs'> + No {providerName} account connected. + </div> + ) : isLoadingAccounts ? ( + <div className='flex items-center gap-2 px-3 py-2 text-muted-foreground text-xs'> + <RefreshCw className='h-3.5 w-3.5 animate-spin' /> + Loading broker accounts... + </div> + ) : accounts.length === 0 ? ( + <div className='px-3 py-2 text-muted-foreground text-xs'> + {accountsQuery.error + ? 'Unable to load broker accounts.' + : 'No broker accounts found.'} + </div> + ) : ( + accounts.map((account) => { + const isSelected = account.id === selectedAccountId + const accountDescription = getAccountDescription(account) + return ( + <DropdownMenuItem + key={account.id} + className={cn(widgetHeaderMenuItemClassName, 'items-center justify-between')} + onSelect={() => { + if (isSelected) return + onAccountSelect?.({ + accountId: account.id, + credentialServiceId: activeServiceId, + }) + }} + > + <span className='flex min-w-0 flex-col'> + <span className='truncate text-foreground'>{getAccountName(account)}</span> + {accountDescription ? ( + <span className='truncate text-[11px] text-muted-foreground'> + {accountDescription} + </span> + ) : null} + </span> + {isSelected ? <Check className='h-3.5 w-3.5 text-primary' /> : null} + </DropdownMenuItem> + ) + }) + )} + + {oauthProvider && credentialServices.serviceIds.length > 0 ? ( + <> + <DropdownMenuSeparator /> + {credentialServices.serviceIds.map((serviceId) => ( + <DropdownMenuItem + key={serviceId} + className={cn(widgetHeaderMenuItemClassName, 'items-center text-foreground')} + onSelect={() => openOAuthModal(serviceId)} + > + <Plus className='h-3.5 w-3.5 text-muted-foreground' /> + <span> + {credentialServices.connectedServiceIds.includes(serviceId) + ? `Reconnect ${getTradingCredentialServiceName(trimmedProviderId, serviceId)} account` + : `Connect ${getTradingCredentialServiceName(trimmedProviderId, serviceId)} account`} + </span> + </DropdownMenuItem> + ))} + </> + ) : null} + </DropdownMenuContent> + </DropdownMenu> + + {oauthProvider ? ( + <OAuthRequiredModal + isOpen={showOAuthModal} + onClose={handleOAuthClose} + provider={oauthProvider} + toolName={toolName} + requiredScopes={providerDefinition?.oauth?.scopes} + serviceId={oauthModalServiceId ?? activeServiceId} + serviceIds={credentialServices.serviceIds} + /> + ) : null} + </> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/components/trading-credential-services.ts b/apps/tradinggoose/widgets/widgets/components/trading-credential-services.ts new file mode 100644 index 000000000..70839d77b --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/trading-credential-services.ts @@ -0,0 +1,68 @@ +'use client' + +import { useOAuthCredentialsByProviderIds } from '@/hooks/queries/oauth-credentials' +import { getServiceByProviderAndId } from '@/lib/oauth' +import { + getTradingProviderDefinition, + getTradingProviderOAuthServiceIds, +} from '@/providers/trading/providers' + +export type TradingCredentialServiceState = { + providerName: string + serviceIds: string[] + connectedServiceIds: string[] + activeServiceId?: string + isLoading: boolean + error: Error | null + refetch: () => void +} + +export function useTradingCredentialServices({ + providerId, + credentialServiceId, + enabled = true, +}: { + providerId?: string | null + credentialServiceId?: string | null + enabled?: boolean +}): TradingCredentialServiceState { + const trimmedProviderId = typeof providerId === 'string' ? providerId.trim() : '' + const requestedServiceId = + typeof credentialServiceId === 'string' ? credentialServiceId.trim() : '' + const providerDefinition = trimmedProviderId + ? getTradingProviderDefinition(trimmedProviderId) + : undefined + const serviceIds = providerDefinition ? getTradingProviderOAuthServiceIds(providerDefinition.id) : [] + const credentialsQuery = useOAuthCredentialsByProviderIds( + serviceIds, + enabled && Boolean(trimmedProviderId) + ) + const credentialsByProviderId = credentialsQuery.data ?? {} + const connectedServiceIds = serviceIds.filter( + (serviceId) => (credentialsByProviderId[serviceId]?.length ?? 0) > 0 + ) + const activeServiceId = + requestedServiceId && serviceIds.includes(requestedServiceId) + ? requestedServiceId + : serviceIds.length === 1 + ? serviceIds[0] + : connectedServiceIds.length === 1 + ? connectedServiceIds[0] + : undefined + + return { + providerName: providerDefinition?.name ?? 'broker', + serviceIds, + connectedServiceIds, + activeServiceId, + isLoading: credentialsQuery.isLoading, + error: credentialsQuery.error instanceof Error ? credentialsQuery.error : null, + refetch: () => { + void credentialsQuery.refetch() + }, + } +} + +export function getTradingCredentialServiceName(providerId: string, serviceId: string) { + return getServiceByProviderAndId(providerId, serviceId).name +} diff --git a/apps/tradinggoose/widgets/widgets/components/trading-provider-controls.tsx b/apps/tradinggoose/widgets/widgets/components/trading-provider-controls.tsx new file mode 100644 index 000000000..52868dc80 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/trading-provider-controls.tsx @@ -0,0 +1,72 @@ +'use client' + +import { cn } from '@/lib/utils' +import { + type TradingAccountSelection, + TradingAccountSelector, +} from '@/widgets/widgets/components/trading-account-selector' +import { + type TradingProviderOption, + TradingProviderSelector, +} from '@/widgets/widgets/components/trading-provider-selector' +import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' + +type TradingProviderControlsProps = { + workspaceId?: string | null + providerId?: string | null + providerOptions: TradingProviderOption[] + onProviderChange?: (providerId: string) => void + credentialServiceId?: string | null + accountId?: string | null + disabled?: boolean + providerPlaceholder?: string + accountPlaceholder?: string + accountTooltipText?: string + toolName?: string + onAccountSelect?: (selection: TradingAccountSelection) => void + className?: string +} + +export function TradingProviderControls({ + workspaceId, + providerId, + providerOptions, + onProviderChange, + credentialServiceId, + accountId, + disabled = false, + providerPlaceholder, + accountPlaceholder = 'Select account', + accountTooltipText = 'Select trading account', + toolName, + onAccountSelect, + className, +}: TradingProviderControlsProps) { + const selectedProviderId = typeof providerId === 'string' ? providerId.trim() : '' + const hasSelectedProvider = Boolean(selectedProviderId) + + return ( + <div className={widgetHeaderButtonGroupClassName(cn('min-w-0', className))}> + <TradingProviderSelector + value={selectedProviderId} + options={providerOptions} + onChange={onProviderChange} + disabled={disabled} + placeholder={providerPlaceholder} + /> + {hasSelectedProvider ? ( + <TradingAccountSelector + workspaceId={workspaceId} + providerId={selectedProviderId} + credentialServiceId={credentialServiceId} + accountId={accountId} + disabled={disabled} + placeholder={accountPlaceholder} + tooltipText={accountTooltipText} + toolName={toolName} + onAccountSelect={onAccountSelect} + /> + ) : null} + </div> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/components/trading-provider-selector.test.tsx b/apps/tradinggoose/widgets/widgets/components/trading-provider-selector.test.tsx new file mode 100644 index 000000000..b8f552b79 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/trading-provider-selector.test.tsx @@ -0,0 +1,66 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { TradingProviderSelector } from '@/widgets/widgets/components/trading-provider-selector' + +describe('TradingProviderSelector', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('renders the selected broker name instead of an icon-only trigger', () => { + act(() => { + root.render( + <TooltipProvider> + <TradingProviderSelector + value='alpaca' + options={[ + { id: 'alpaca', name: 'Alpaca' }, + { id: 'tradier', name: 'Tradier' }, + ]} + /> + </TooltipProvider> + ) + }) + + const button = container.querySelector('button[aria-label="Select trading provider"]') + expect(button?.textContent).toContain('Broker: Alpaca') + }) + + it('renders a clear placeholder before a broker is selected', () => { + act(() => { + root.render( + <TooltipProvider> + <TradingProviderSelector + value='' + options={[{ id: 'alpaca', name: 'Alpaca' }]} + placeholder='Select broker' + /> + </TooltipProvider> + ) + }) + + const button = container.querySelector('button[aria-label="Select trading provider"]') + expect(button?.textContent).toContain('Select broker') + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/components/trading-provider-selector.tsx b/apps/tradinggoose/widgets/widgets/components/trading-provider-selector.tsx new file mode 100644 index 000000000..8a0273524 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/trading-provider-selector.tsx @@ -0,0 +1,156 @@ +'use client' + +import { useMemo } from 'react' +import { Check, ChevronDown } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { OAUTH_PROVIDERS, parseProvider } from '@/lib/oauth' +import { cn } from '@/lib/utils' +import { getTradingProviderDefinition } from '@/providers/trading/providers' +import { + widgetHeaderControlClassName, + widgetHeaderMenuContentClassName, + widgetHeaderMenuItemClassName, + widgetHeaderMenuTextClassName, +} from '@/widgets/widgets/components/widget-header-control' + +export type TradingProviderOption = { + id: string + name: string +} + +export const resolveTradingProviderIcon = (providerId?: string) => { + if (!providerId) { + return undefined + } + + const providerDefinition = getTradingProviderDefinition(providerId) + if (providerDefinition?.icon) { + return providerDefinition.icon + } + + const oauthProvider = providerDefinition?.oauth?.provider + if (!oauthProvider) { + return undefined + } + + return OAUTH_PROVIDERS[parseProvider(oauthProvider).baseProvider]?.icon +} + +type TradingProviderSelectorProps = { + value?: string | null + options: TradingProviderOption[] + onChange?: (providerId: string) => void + disabled?: boolean + placeholder?: string + triggerClassName?: string + menuClassName?: string +} + +const DEFAULT_PLACEHOLDER = 'Select provider' + +export function TradingProviderSelector({ + value, + options, + onChange, + disabled = false, + placeholder = DEFAULT_PLACEHOLDER, + triggerClassName, + menuClassName, +}: TradingProviderSelectorProps) { + const optionsWithIcons = useMemo( + () => + options.map((option) => ({ + ...option, + icon: resolveTradingProviderIcon(option.id), + })), + [options] + ) + const selectedOption = optionsWithIcons.find((option) => option.id === value) ?? null + const label = selectedOption ? `Broker: ${selectedOption.name}` : placeholder + const SelectedIcon = selectedOption?.icon + const isDropdownDisabled = disabled || optionsWithIcons.length === 0 + const tooltipText = isDropdownDisabled ? 'Provider selection unavailable' : 'Select broker' + + return ( + <DropdownMenu modal={false}> + <Tooltip> + <TooltipTrigger asChild> + <span className='inline-flex'> + <DropdownMenuTrigger asChild> + <button + type='button' + disabled={isDropdownDisabled} + className={widgetHeaderControlClassName( + cn('group flex justify-between', triggerClassName) + )} + aria-haspopup='listbox' + aria-label='Select trading provider' + > + <span className='flex min-w-0 items-center gap-1.5'> + {SelectedIcon ? ( + <SelectedIcon + className='h-4 w-4 shrink-0 text-muted-foreground' + aria-hidden='true' + /> + ) : null} + <span + className={cn( + 'min-w-0 text-left', + selectedOption ? 'text-foreground' : 'text-muted-foreground' + )} + > + {label} + </span> + </span> + <ChevronDown + className='h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180' + aria-hidden='true' + /> + </button> + </DropdownMenuTrigger> + </span> + </TooltipTrigger> + <TooltipContent side='top'>{tooltipText}</TooltipContent> + </Tooltip> + <DropdownMenuContent + sideOffset={6} + className={cn(widgetHeaderMenuContentClassName, 'w-[220px]', menuClassName)} + > + {optionsWithIcons.length === 0 ? ( + <div className='px-2 py-2 text-muted-foreground text-xs'>No providers</div> + ) : ( + optionsWithIcons.map((option) => { + const Icon = option.icon + const isSelected = option.id === value + + return ( + <DropdownMenuItem + key={option.id} + className={cn(widgetHeaderMenuItemClassName, 'items-center')} + onSelect={() => { + if (option.id === value) return + onChange?.(option.id) + }} + > + {Icon ? ( + <Icon + className={cn('h-4 w-4 text-muted-foreground', isSelected && 'text-foreground')} + aria-hidden='true' + /> + ) : null} + <span className={cn(widgetHeaderMenuTextClassName, 'truncate')}>{option.name}</span> + {isSelected ? <Check className='ml-auto h-3.5 w-3.5 text-primary' /> : null} + </DropdownMenuItem> + ) + }) + )} + </DropdownMenuContent> + </DropdownMenu> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/components/widget-header-control.ts b/apps/tradinggoose/widgets/widgets/components/widget-header-control.ts index 122f65a20..6942a4a4d 100644 --- a/apps/tradinggoose/widgets/widgets/components/widget-header-control.ts +++ b/apps/tradinggoose/widgets/widgets/components/widget-header-control.ts @@ -4,7 +4,7 @@ import { useCallback, useRef, useState } from 'react' import { cn } from '@/lib/utils' const BASE_CONTROL_CLASS = - 'inline-flex h-7 items-center gap-1 rounded-sm border border-border/70 bg-background px-2 p-1 text-xs font-medium text-foreground transition-colors hover:bg-card disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 hover:border-border hover:shadwow-sm' + 'inline-flex h-7 items-center gap-1 rounded-sm border border-border/70 bg-background px-2 p-1 text-xs font-medium text-foreground transition-colors hover:bg-card disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 hover:border-border hover:shadow-sm' const ICON_BUTTON_CLASS = cn( BASE_CONTROL_CLASS, 'h-7 w-7 shrink-0 justify-center shadow-xs text-muted-foreground hover:text-foreground' diff --git a/apps/tradinggoose/widgets/widgets/components/widget-header-refresh-button.tsx b/apps/tradinggoose/widgets/widgets/components/widget-header-refresh-button.tsx new file mode 100644 index 000000000..a9df9f685 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/widget-header-refresh-button.tsx @@ -0,0 +1,39 @@ +'use client' + +import { RefreshCw } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { widgetHeaderIconButtonClassName } from '@/widgets/widgets/components/widget-header-control' + +type WidgetHeaderRefreshButtonProps = { + disabled?: boolean + label?: string + tooltip?: string + onClick: () => void +} + +export function WidgetHeaderRefreshButton({ + disabled = false, + label = 'Refresh data', + tooltip, + onClick, +}: WidgetHeaderRefreshButtonProps) { + return ( + <Tooltip> + <TooltipTrigger asChild> + <span className='inline-flex'> + <button + type='button' + className={widgetHeaderIconButtonClassName()} + onClick={onClick} + disabled={disabled} + aria-label={label} + > + <RefreshCw className='h-3.5 w-3.5' /> + <span className='sr-only'>{label}</span> + </button> + </span> + </TooltipTrigger> + <TooltipContent side='top'>{tooltip ?? label}</TooltipContent> + </Tooltip> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/components/widget-selector.test.tsx b/apps/tradinggoose/widgets/widgets/components/widget-selector.test.tsx new file mode 100644 index 000000000..9a4215171 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/components/widget-selector.test.tsx @@ -0,0 +1,67 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { WidgetSelectorComponent } from '@/widgets/widgets/components/widget-selector' + +describe('WidgetSelectorComponent', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('renders trading first and includes watchlist under Trading', async () => { + await act(async () => { + root.render( + <TooltipProvider> + <WidgetSelectorComponent + currentKey='heatmap' + renderTrigger={() => <button type='button'>Select widget</button>} + /> + </TooltipProvider> + ) + }) + + await act(async () => { + container.querySelector('button')?.dispatchEvent( + new MouseEvent('pointerdown', { + bubbles: true, + }) + ) + }) + + const content = document.body.textContent ?? '' + + expect(content.indexOf('Trading')).toBeLessThan(content.indexOf('Lists')) + expect(content.indexOf('Lists')).toBeLessThan(content.indexOf('Editor')) + expect(content.indexOf('Editor')).toBeLessThan(content.indexOf('Utils')) + + const tradingStart = content.indexOf('Trading') + const listsStart = content.indexOf('Lists') + const tradingSection = content.slice(tradingStart, listsStart) + + expect(tradingSection).toContain('Watchlist') + expect(tradingSection).toContain('Heatmap') + expect(tradingSection).toContain('Portfolio Snapshot') + expect(tradingSection).toContain('Quick Order') + expect(tradingSection).toContain('Data Chart') + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/components/widget-selector.tsx b/apps/tradinggoose/widgets/widgets/components/widget-selector.tsx index 6982f3b08..3ba0aeabf 100644 --- a/apps/tradinggoose/widgets/widgets/components/widget-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/components/widget-selector.tsx @@ -2,12 +2,15 @@ import { cloneElement, isValidElement, memo, type ReactElement, useMemo } from 'react' import { ChevronDown } from 'lucide-react' +import { useLocale } from 'next-intl' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { getWidgetCategories, getWidgetDefinition } from '@/widgets/registry' @@ -20,7 +23,7 @@ import { widgetHeaderMenuTextClassName, } from '@/widgets/widgets/components/widget-header-control' -interface WidgetSelectorProps { +export interface WidgetSelectorProps { currentKey?: string | null onSelect?: (widgetKey: string) => void disabled?: boolean @@ -35,13 +38,25 @@ type TriggerElementProps = { 'aria-disabled'?: boolean } -function WidgetSelectorComponent({ +export function WidgetSelectorComponent({ currentKey, onSelect, disabled, renderTrigger, }: WidgetSelectorProps) { - const categories = useMemo(() => getWidgetCategories(), []) + const locale = useLocale() as LocaleCode + const categories = useMemo(() => getWidgetCategories(locale), [locale]) + const selectorCopy = getPublicCopy(locale).workspace.widgets.selector + const visibleCategories = useMemo( + () => + categories + .map((category) => ({ + ...category, + widgets: category.widgets.filter((widget) => widget.key !== 'empty'), + })) + .filter((category) => category.widgets.length > 0), + [categories] + ) const currentDefinition: DashboardWidgetDefinition | undefined = useMemo( () => getWidgetDefinition(currentKey ?? 'empty') ?? getWidgetDefinition('empty'), [currentKey] @@ -77,7 +92,9 @@ function WidgetSelectorComponent({ }) : triggerContent - const tooltipText = triggerDisabled ? 'Widget selection unavailable' : 'Select widget' + const tooltipText = triggerDisabled + ? selectorCopy.widgetSelectionUnavailable + : selectorCopy.selectWidget return ( <DropdownMenu> @@ -91,20 +108,17 @@ function WidgetSelectorComponent({ </Tooltip> <DropdownMenuContent sideOffset={6} - className={cn(widgetHeaderMenuContentClassName, 'w-[540px] max-w-[calc(100vw-2rem)] p-2')} + className={cn(widgetHeaderMenuContentClassName, 'w-[720px] max-w-[calc(100vw-2rem)] p-2')} > - <div className='grid grid-cols-3'> - {categories.map((category) => { - const visibleWidgets = category.widgets.filter((widget) => widget.key !== 'empty') - if (visibleWidgets.length === 0) return null - + <div className='grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'> + {visibleCategories.map((category) => { return ( <div key={category.key} className=''> <div> <p className='font-semibold text-xs uppercase tracking-wide '>{category.title}</p> </div> <div className='space-y-1'> - {visibleWidgets.map((widget) => ( + {category.widgets.map((widget) => ( <DropdownMenuItem key={widget.key} className={cn(widgetHeaderMenuItemClassName, 'items-start items-center')} diff --git a/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx index 6f5cb7339..1962a6a13 100644 --- a/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx @@ -12,6 +12,9 @@ import { import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useLocale } from 'next-intl' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -25,7 +28,6 @@ import { widgetHeaderMenuTextClassName, } from '@/widgets/widgets/components/widget-header-control' -const DEFAULT_PLACEHOLDER = 'Select workflow' const DROPDOWN_MAX_HEIGHT = '20rem' const DROPDOWN_VIEWPORT_HEIGHT = '14rem' @@ -47,13 +49,15 @@ export function WorkflowDropdown({ value, onChange, disabled = false, - placeholder = DEFAULT_PLACEHOLDER, + placeholder, pairColor, align = 'start', triggerClassName, menuClassName, includeMarketplace = true, }: WorkflowDropdownProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.workflowDropdown const [internalValue, setInternalValue] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null) const [hasRequestedLoad, setHasRequestedLoad] = useState(false) @@ -102,12 +106,13 @@ export function WorkflowDropdown({ const isLoading = metadataHydrationPhase === 'metadata-loading' const isDropdownDisabled = disabled || !workspaceId const tooltipText = !workspaceId - ? 'Select a workspace to choose workflows' + ? copy.selectWorkspaceFirst : loadError - ? 'Unable to load workflows' + ? copy.unableToLoad : disabled - ? 'Workflow selection unavailable' - : 'Select workflow' + ? copy.workflowSelectionUnavailable + : copy.selectWorkflow + const resolvedPlaceholder = placeholder ?? copy.selectWorkflow // Reset internal state when workspace changes useEffect(() => { @@ -119,28 +124,6 @@ export function WorkflowDropdown({ } }, [workspaceId, isControlled]) - // Request workflows for workspace when needed - useEffect(() => { - if (!workspaceId || workspaceWorkflows.length > 0 || hasRequestedLoad) { - return - } - - let cancelled = false - setHasRequestedLoad(true) - setLoadError(null) - - loadWorkflows({ workspaceId, channelId: metadataChannelId }).catch((error) => { - if (!cancelled) { - console.error('Failed to load workflows for workflow dropdown', error) - setLoadError('Failed to load workflows') - } - }) - - return () => { - cancelled = true - } - }, [workspaceId, workspaceWorkflows.length, hasRequestedLoad, loadWorkflows, metadataChannelId]) - // Keep internal selection in sync with pair context when uncontrolled useEffect(() => { if (isControlled || !isPairContextActive) { @@ -157,7 +140,7 @@ export function WorkflowDropdown({ } setInternalValue(nextId) - }, [isControlled, resolvedPairColor, pairContext?.workflowId, workspaceWorkflows]) + }, [isControlled, isPairContextActive, pairContext?.workflowId, workspaceWorkflows]) // Fallback to first available workflow when uncontrolled useEffect(() => { @@ -168,6 +151,35 @@ export function WorkflowDropdown({ setInternalValue(workspaceWorkflows[0].id) }, [isControlled, internalValue, workspaceWorkflows]) + // Request workflows for workspace when needed + useEffect(() => { + if (!workspaceId || workspaceWorkflows.length > 0 || hasRequestedLoad) { + return + } + + let cancelled = false + setHasRequestedLoad(true) + setLoadError(null) + + loadWorkflows({ workspaceId, channelId: metadataChannelId }).catch((error) => { + if (!cancelled) { + console.error('Failed to load workflows for workflow dropdown', error) + setLoadError(copy.failedToLoad) + } + }) + + return () => { + cancelled = true + } + }, [ + workspaceId, + workspaceWorkflows.length, + hasRequestedLoad, + loadWorkflows, + metadataChannelId, + copy.failedToLoad, + ]) + const handleSelect = (workflow: WorkflowMetadata) => { if (!workflow) { return @@ -206,7 +218,7 @@ export function WorkflowDropdown({ if (!normalizedQuery) return workspaceWorkflows return workspaceWorkflows.filter((workflow) => { - const name = workflow.name || 'Untitled workflow' + const name = workflow.name || copy.untitledWorkflow return ( name.toLowerCase().includes(normalizedQuery) || workflow.id.toLowerCase().includes(normalizedQuery) @@ -218,7 +230,7 @@ export function WorkflowDropdown({ if (!workspaceId) { return ( <p className='px-2 py-4 text-center text-muted-foreground text-xs'> - Select a workspace first. + {copy.selectWorkspaceFirst} </p> ) } @@ -226,13 +238,13 @@ export function WorkflowDropdown({ if (loadError) { return ( <div className='space-y-2 px-3 py-2 text-xs'> - <p className='text-destructive'>{loadError}. Try reloading the widget.</p> + <p className='text-destructive'>{loadError}</p> <button type='button' className='font-semibold text-primary text-xs hover:underline' onClick={handleRetry} > - Retry + {copy.retry} </button> </div> ) @@ -244,7 +256,7 @@ export function WorkflowDropdown({ return ( <div className='flex items-center gap-1 px-3 py-2 text-muted-foreground text-xs'> <Loader2 className='h-3.5 w-3.5 animate-spin' /> - Loading workflows… + {copy.loading} </div> ) } @@ -252,7 +264,7 @@ export function WorkflowDropdown({ if (filteredWorkflows.length === 0) { return ( <p className='px-2 py-4 text-center text-muted-foreground text-xs'> - {searchQuery.trim() ? 'No workflows found.' : 'No workflows available yet.'} + {searchQuery.trim() ? copy.noWorkflowsFound : copy.noWorkflowsAvailable} </p> ) } @@ -286,7 +298,7 @@ export function WorkflowDropdown({ /> </span> <span className={cn(widgetHeaderMenuTextClassName, 'truncate')}> - {workflow.name || 'Untitled workflow'} + {workflow.name || copy.untitledWorkflow} </span> </div> {isSelected ? <Check className='h-3.5 w-3.5 text-primary' /> : null} @@ -314,11 +326,11 @@ export function WorkflowDropdown({ const labelContent = selectedWorkflow ? ( <span className='min-w-0 flex-1 truncate text-left font-medium text-foreground text-sm'> - {selectedWorkflow.name || 'Untitled workflow'} + {selectedWorkflow.name || copy.untitledWorkflow} </span> ) : ( <span className='min-w-0 flex-1 truncate text-left font-medium text-muted-foreground text-sm'> - {placeholder} + {resolvedPlaceholder} </span> ) @@ -363,10 +375,10 @@ export function WorkflowDropdown({ <div className='border-border/70 border-b p-2'> <div className='flex items-center gap-1 rounded-md border bg-background px-2 py-1.5 text-muted-foreground text-sm'> <Search className='h-3.5 w-3.5 shrink-0' /> - <Input + <Input value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} - placeholder='Search workflows...' + 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' diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.test.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.test.tsx index a60f0b7f3..3e5127414 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.test.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.test.tsx @@ -10,46 +10,18 @@ import { CopilotApp } from './copilot-app' const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + const mockResolveEntityReviewTarget = vi.fn() const mockUnregisteredReviewSessionIds = new Set<string>() -const mockSetPairColorContext = vi.fn() -const mockSaveChatMessages = vi.fn(async () => {}) const mockCopilot = vi.fn((props: any) => ( <div data-testid='copilot' data-input-disabled={String(Boolean(props.inputDisabled))}> copilot </div> )) -let mockLiveWorkflowId: string | null = null -let mockLiveTarget: any = { - reviewSessionId: null, - entityKind: null, - entityId: null, - draftSessionId: null, +let mockPairContext: any = { + workflowId: null, skillId: null, } -let mockCopilotStoreState: any = null -const mockCopilotStoreApi = { - getState: () => mockCopilotStoreState, - setState: (partial: any) => { - const nextState = typeof partial === 'function' ? partial(mockCopilotStoreState) : partial - mockCopilotStoreState = { - ...mockCopilotStoreState, - ...nextState, - } - }, -} -const applyMockPairColorContext = (color: string, context: any) => { - mockSetPairColorContext(color, context) - mockLiveWorkflowId = context?.workflowId ?? mockLiveWorkflowId - mockLiveTarget = { - ...mockLiveTarget, - skillId: context?.skillId ?? mockLiveTarget.skillId ?? null, - reviewSessionId: context?.reviewTarget?.reviewSessionId ?? null, - entityKind: context?.reviewTarget?.reviewEntityKind ?? null, - entityId: context?.reviewTarget?.reviewEntityId ?? null, - draftSessionId: context?.reviewTarget?.reviewDraftSessionId ?? null, - } -} vi.mock('@/lib/auth-client', () => ({ useSession: () => ({ @@ -98,23 +70,19 @@ vi.mock('@/lib/yjs/workflow-session-host', () => ({ vi.mock('@/stores/copilot/store', () => ({ DEFAULT_COPILOT_CHANNEL_ID: 'default', CopilotStoreProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, - useCopilotStoreApi: () => mockCopilotStoreApi, + useCopilotStoreApi: () => ({ + getState: () => ({ + currentChat: null, + chats: [], + messages: [], + saveChatMessages: vi.fn(async () => {}), + }), + setState: vi.fn(), + }), })) vi.mock('@/stores/dashboard/pair-store', () => ({ - usePairColorContext: () => ({ - workflowId: mockLiveWorkflowId, - skillId: mockLiveTarget.skillId, - reviewTarget: mockLiveTarget.entityKind - ? { - reviewSessionId: mockLiveTarget.reviewSessionId, - reviewEntityKind: mockLiveTarget.entityKind, - reviewEntityId: mockLiveTarget.entityId, - reviewDraftSessionId: mockLiveTarget.draftSessionId, - } - : undefined, - }), - useSetPairColorContext: () => applyMockPairColorContext, + usePairColorContext: () => mockPairContext, })) vi.mock('@/widgets/widgets/entity_review/review-target-utils', async (importOriginal) => { @@ -145,63 +113,13 @@ describe('CopilotApp', () => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - mockLiveTarget = { - reviewSessionId: null, - entityKind: null, - entityId: null, - draftSessionId: null, + mockPairContext = { + workflowId: null, skillId: null, } - mockLiveWorkflowId = null mockResolveEntityReviewTarget.mockReset() - mockSetPairColorContext.mockReset() mockCopilot.mockClear() mockUnregisteredReviewSessionIds.clear() - mockSaveChatMessages.mockReset() - mockSaveChatMessages.mockResolvedValue(undefined) - mockCopilotStoreState = { - currentChat: { - reviewSessionId: 'chat-1', - workspaceId: 'ws-1', - channelId: 'workflow', - entityKind: 'copilot', - entityId: null, - draftSessionId: null, - title: 'Copilot chat', - messages: [], - messageCount: 0, - createdAt: new Date('2026-04-16T00:00:00.000Z'), - updatedAt: new Date('2026-04-16T00:00:00.000Z'), - }, - chats: [ - { - reviewSessionId: 'chat-1', - workspaceId: 'ws-1', - channelId: 'workflow', - entityKind: 'copilot', - entityId: null, - draftSessionId: null, - title: 'Copilot chat', - messages: [], - messageCount: 0, - createdAt: new Date('2026-04-16T00:00:00.000Z'), - updatedAt: new Date('2026-04-16T00:00:00.000Z'), - }, - ], - messages: [], - saveChatMessages: mockSaveChatMessages, - } - mockResolveEntityReviewTarget.mockResolvedValue({ - descriptor: { - workspaceId: 'ws-1', - entityKind: 'skill', - entityId: null, - draftSessionId: 'draft-1', - reviewSessionId: 'review-1', - yjsSessionId: 'review-1', - }, - runtime: null, - }) }) afterEach(() => { @@ -212,7 +130,7 @@ describe('CopilotApp', () => { reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false }) - it('renders copilot without a target session when no live target is pinned', async () => { + it('renders copilot without session hosts when no shared workflow is pinned', async () => { await renderApp() expect(container.querySelector('[data-testid="entity-session-host"]')).toBeNull() @@ -220,26 +138,11 @@ describe('CopilotApp', () => { expect(container.querySelector('[data-testid="copilot"]')).not.toBeNull() }) - it('mounts the entity session host for non-workflow review targets', async () => { - mockLiveTarget = { - reviewSessionId: 'review-1', - entityKind: 'skill', - entityId: 'skill-review-target', - draftSessionId: 'draft-1', - skillId: 'skill-current-context', - } - - await renderApp() - - expect(container.querySelector('[data-testid="entity-session-host"]')).not.toBeNull() - expect(container.querySelector('[data-testid="entity-session-host"]')).toHaveAttribute( - 'data-entity-id', - 'skill-review-target' - ) - }) - it('mounts the workflow session host for the current pair-color workflow', async () => { - mockLiveWorkflowId = 'workflow-current' + mockPairContext = { + workflowId: 'workflow-current', + skillId: null, + } await renderApp() @@ -249,37 +152,9 @@ describe('CopilotApp', () => { ) }) - it('resolves and mounts explicit draft review targets', async () => { - mockLiveTarget = { - reviewSessionId: null, - entityKind: 'skill', - entityId: null, - draftSessionId: 'draft-1', - } - - await renderApp() - - expect(container.querySelector('[data-testid="entity-session-host"]')).not.toBeNull() - expect(container.querySelector('[data-testid="entity-session-host"]')).toHaveAttribute( - 'data-review-session-id', - 'review-1' - ) - expect(container.querySelector('[data-testid="entity-session-host"]')).toHaveAttribute( - 'data-draft-session-id', - 'draft-1' - ) - expect(container.querySelector('[data-testid="copilot"]')).toHaveAttribute( - 'data-input-disabled', - 'false' - ) - }) - - it('does not resolve or mount plain non-workflow color-store entity targets', async () => { - mockLiveTarget = { - reviewSessionId: null, - entityKind: null, - entityId: null, - draftSessionId: null, + it('does not resolve or mount plain non-workflow shared entity ids', async () => { + mockPairContext = { + workflowId: null, skillId: 'skill-plain', } @@ -293,105 +168,25 @@ describe('CopilotApp', () => { ) }) - it('keeps input disabled until resolved entity sessions are registered', async () => { - mockUnregisteredReviewSessionIds.add('review-1') - mockLiveTarget = { - reviewSessionId: null, - entityKind: 'skill', - entityId: null, - draftSessionId: 'draft-1', - skillId: null, - } - - await renderApp() - - expect(container.querySelector('[data-testid="entity-session-host"]')).not.toBeNull() - expect(container.querySelector('[data-testid="copilot"]')).toHaveAttribute( - 'data-input-disabled', - 'true' - ) - }) - - it('rejects failed review target resolution, clears the stale pair target, and unlocks input', async () => { - mockResolveEntityReviewTarget.mockRejectedValueOnce(new Error('Forbidden')) - mockLiveTarget = { - reviewSessionId: null, - entityKind: 'skill', - entityId: null, - draftSessionId: 'draft-stale', + it('ignores forced pair review metadata now that review state is widget-local', async () => { + mockPairContext = { + workflowId: null, skillId: 'skill-current-context', + reviewTarget: { + reviewSessionId: 'review-1', + reviewEntityKind: 'skill', + reviewEntityId: null, + reviewDraftSessionId: 'draft-1', + }, } await renderApp() - await act(async () => { - await Promise.resolve() - }) + expect(mockResolveEntityReviewTarget).not.toHaveBeenCalled() expect(container.querySelector('[data-testid="entity-session-host"]')).toBeNull() expect(container.querySelector('[data-testid="copilot"]')).toHaveAttribute( 'data-input-disabled', 'false' ) - expect(mockSetPairColorContext).toHaveBeenCalledWith( - 'gray', - expect.objectContaining({ - skillId: 'skill-current-context', - reviewTarget: null, - }) - ) - expect(mockLiveTarget.entityKind).toBeNull() - expect(mockCopilotStoreState.messages).toHaveLength(1) - expect(mockCopilotStoreState.messages[0]).toMatchObject({ - role: 'assistant', - }) - expect(mockCopilotStoreState.messages[0].content).toContain('was rejected') - expect(mockSaveChatMessages).toHaveBeenCalledWith('chat-1') - }) - - it('does not mount a workflow session when only a workflow review target is present', async () => { - mockLiveTarget = { - reviewSessionId: 'review-workflow-1', - entityKind: 'workflow', - entityId: 'workflow-target', - draftSessionId: null, - } - await renderApp() - - expect(container.querySelector('[data-testid="workflow-session-host"]')).toBeNull() - }) - - it('does not keep a stale resolved draft entity mounted after the live target changes', async () => { - mockResolveEntityReviewTarget - .mockResolvedValueOnce({ - descriptor: { - workspaceId: 'ws-1', - entityKind: 'skill', - entityId: null, - draftSessionId: 'draft-1', - reviewSessionId: 'review-1', - yjsSessionId: 'review-1', - }, - runtime: null, - }) - .mockImplementationOnce(() => new Promise(() => {})) - - mockLiveTarget = { - reviewSessionId: null, - entityKind: 'skill', - entityId: null, - draftSessionId: 'draft-1', - } - await renderApp() - expect(container.querySelector('[data-testid="entity-session-host"]')).not.toBeNull() - - mockLiveTarget = { - reviewSessionId: null, - entityKind: 'skill', - entityId: null, - draftSessionId: 'draft-2', - } - await renderApp() - - expect(container.querySelector('[data-testid="entity-session-host"]')).toBeNull() }) }) diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.tsx index e2acd6709..2c5540458 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.tsx @@ -12,7 +12,7 @@ import { DEFAULT_COPILOT_CHANNEL_ID, useCopilotStoreApi, } from '@/stores/copilot/store' -import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' +import { usePairColorContext } from '@/stores/dashboard/pair-store' import type { PairColor } from '@/widgets/pair-colors' import { buildCopilotEditableReviewTargets, @@ -88,24 +88,17 @@ const CopilotAppContent = ({ | undefined }) => { const pairContext = usePairColorContext(pairColor) - const setPairColorContext = useSetPairColorContext() const copilotStoreApi = useCopilotStoreApi() const workflowId = resolveCopilotWorkflowId(pairContext) ?? null const editableReviewTargets = useMemo( () => buildCopilotEditableReviewTargets({ pairContext }), - [ - pairContext?.reviewTarget?.reviewSessionId, - pairContext?.reviewTarget?.reviewEntityKind, - pairContext?.reviewTarget?.reviewEntityId, - pairContext?.reviewTarget?.reviewDraftSessionId, - ] + [pairContext] ) const [resolvedEntityTargets, setResolvedEntityTargets] = useState<{ key: string descriptors: ReviewTargetDescriptor[] } | null>(null) const lastRejectedResolutionKeyRef = useRef<string | null>(null) - const pairContextRef = useRef(pairContext) // Copilot history is workspace-scoped, while runtime edits still follow the // active widget channel through pair/panel context. const entityTargetResolution = useMemo(() => { @@ -146,10 +139,6 @@ const CopilotAppContent = ({ } }, [editableReviewTargets, workspaceId]) - useEffect(() => { - pairContextRef.current = pairContext - }, [pairContext]) - useEffect(() => { if (!entityTargetResolution.unresolvedKey) { setResolvedEntityTargets(null) @@ -189,11 +178,6 @@ const CopilotAppContent = ({ if (lastRejectedResolutionKeyRef.current !== rejectedKey) { lastRejectedResolutionKeyRef.current = rejectedKey - setPairColorContext(pairColor, { - ...(pairContextRef.current ?? {}), - reviewTarget: null, - }) - const noticeContent = buildRejectedReviewTargetMessage( entityTargetResolution.unresolved ) @@ -240,7 +224,7 @@ const CopilotAppContent = ({ return () => { cancelled = true } - }, [copilotStoreApi, entityTargetResolution, pairColor, setPairColorContext]) + }, [copilotStoreApi, entityTargetResolution]) const entityDescriptors = useMemo( () => [ diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/assistant-message-segments.ts b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/assistant-message-segments.ts index b7fbd12d8..ad4378baa 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/assistant-message-segments.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/assistant-message-segments.ts @@ -7,20 +7,20 @@ type ToolCallContentBlock = Extract<AssistantContentBlock, { type: 'tool_call' } export type AssistantMessageSegment = | { - type: 'thinking' - key: string - blocks: ThinkingContentBlock[] - } + type: 'thinking' + key: string + blocks: ThinkingContentBlock[] + } | { - type: 'text' - key: string - block: TextContentBlock - } + type: 'text' + key: string + block: TextContentBlock + } | { - type: 'tool_call' - key: string - block: ToolCallContentBlock - } + type: 'tool_call' + key: string + block: ToolCallContentBlock + } export function buildAssistantMessageSegments( contentBlocks?: CopilotMessage['contentBlocks'] diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.test.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.test.tsx index 14f931d8d..17ebe2adc 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.test.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.test.tsx @@ -56,4 +56,41 @@ describe('ThinkingGroup', () => { expect(container.textContent).toContain('Thought for 1.3s') expect(container.textContent).not.toContain('Thinking...') }) + + it('does not show a fake zero duration when timing is unavailable', async () => { + const blocks = [ + { + type: 'thinking' as const, + content: 'Historical reasoning.', + timestamp: 1, + itemId: 'thinking-1', + }, + ] + + await act(async () => { + root.render(<ThinkingGroup blocks={blocks} isStreaming={false} />) + }) + + expect(container.textContent).toContain('Finished thinking') + expect(container.textContent).not.toContain('Thought for 0ms') + }) + + it('renders expanded thinking content as markdown', async () => { + const blocks = [ + { + type: 'thinking' as const, + content: 'Inspecting **workflow**.\n\n- Validate edges', + timestamp: 1, + itemId: 'thinking-1', + }, + ] + + await act(async () => { + root.render(<ThinkingGroup blocks={blocks} isStreaming={true} />) + }) + + expect(container.querySelector('strong')?.textContent).toBe('workflow') + expect(container.querySelector('ul')?.textContent).toContain('Validate edges') + expect(container.textContent).not.toContain('**workflow**') + }) }) diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx index 779c4e4a4..e8e50b1bd 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Brain, ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' import type { CopilotMessage } from '@/stores/copilot/types' +import CopilotMarkdownRenderer from './markdown-renderer' type ThinkingContentBlock = Extract< NonNullable<CopilotMessage['contentBlocks']>[number], @@ -23,16 +24,17 @@ function formatDuration(ms: number) { return `${(ms / 1000).toFixed(1)}s` } -function getThinkingDuration(block: ThinkingContentBlock) { - if (typeof block.duration === 'number') { +function getThinkingDuration(block: ThinkingContentBlock): number | null { + if (typeof block.duration === 'number' && block.duration > 0) { return block.duration } if (typeof block.startTime === 'number') { - return Date.now() - block.startTime + const duration = Date.now() - block.startTime + return duration > 0 ? duration : null } - return 0 + return null } export function ThinkingGroup({ blocks, isStreaming = false }: ThinkingGroupProps) { @@ -48,10 +50,13 @@ export function ThinkingGroup({ blocks, isStreaming = false }: ThinkingGroupProp [blocks] ) - const totalDuration = useMemo( - () => blocks.reduce((sum, block) => sum + getThinkingDuration(block), 0), - [blocks] - ) + const totalDuration = useMemo(() => { + let total = 0 + for (const block of blocks) { + total += getThinkingDuration(block) ?? 0 + } + return total + }, [blocks]) useEffect(() => { if (!isStreaming) { @@ -65,7 +70,11 @@ export function ThinkingGroup({ blocks, isStreaming = false }: ThinkingGroupProp } }, [content, isStreaming]) - const headerLabel = isStreaming ? 'Thinking...' : `Thought for ${formatDuration(totalDuration)}` + const headerLabel = isStreaming + ? 'Thinking...' + : totalDuration > 0 + ? `Thought for ${formatDuration(totalDuration)}` + : 'Finished thinking' return ( <div className='w-full rounded-md border border-border/60 bg-muted/30'> @@ -97,12 +106,10 @@ export function ThinkingGroup({ blocks, isStreaming = false }: ThinkingGroupProp {isExpanded && content ? ( <div className='border-border/60 border-t px-3 py-2'> - <pre className='whitespace-pre-wrap break-words font-mono text-[11px] text-muted-foreground leading-5'> - {content} - {isStreaming ? ( - <span className='ml-1 inline-block h-2 w-1 animate-pulse bg-muted-foreground/80 align-middle' /> - ) : null} - </pre> + <CopilotMarkdownRenderer content={content} /> + {isStreaming ? ( + <span className='ml-1 inline-block h-2 w-1 animate-pulse bg-muted-foreground/80 align-middle' /> + ) : null} </div> ) : null} </div> 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..677af7670 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 { useLocale } from 'next-intl' import { Button, DropdownMenu, @@ -16,6 +17,11 @@ import { } from '@/lib/copilot/runtime-models' import { cn } from '@/lib/utils' import { useCopilotStore } from '@/stores/copilot/store' +import type { LocaleCode } from '@/i18n/utils' +import { + getWorkflowLabelCopy, + translateWorkflowLabel, +} from '@/widgets/workflow-labels' import { ANTHROPIC_MODELS, BRAIN_CIRCUIT_MODELS, @@ -46,7 +52,9 @@ const getModelOptionIcon = (modelValue: CopilotRuntimeModel) => { } export function ModelSelector({ isNearTop, panelWidth }: ModelSelectorProps) { + const locale = useLocale() as LocaleCode const { agentPrefetch, selectedModel, setAgentPrefetch, setSelectedModel } = useCopilotStore() + const copy = getWorkflowLabelCopy(locale) const model = COPILOT_RUNTIME_MODEL_OPTIONS.find((option) => option.value === selectedModel) const collapsedModeLabel = model ? model.label : DEFAULT_COPILOT_RUNTIME_MODEL @@ -58,13 +66,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={translateWorkflowLabel(locale, 'Choose model')} > {getModelOptionIcon(selectedModel)} <span className={cn(panelWidth < 360 ? 'max-w-[72px] truncate' : '')}> {collapsedModeLabel} {agentPrefetch && !FAST_MODELS.includes(selectedModel) && ( - <span className='ml-1 font-semibold'>Lite</span> + <span className='ml-1 font-semibold'>{translateWorkflowLabel(locale, 'Lite')}</span> )} </span> </Button> @@ -75,12 +83,12 @@ export function ModelSelector({ isNearTop, panelWidth }: ModelSelectorProps) { <div className='max-h-[280px] overflow-y-auto p-2'> <div> <div className='mb-1'> - <span className='font-medium text-xs'>Model</span> + <span className='font-medium text-xs'>{copy.model}</span> </div> <div className='space-y-2'> <div> <div className='px-2 py-1 font-medium text-[10px] text-muted-foreground uppercase'> - Anthropic + {translateWorkflowLabel(locale, 'Anthropic')} </div> <div className='space-y-0.5'> {COPILOT_RUNTIME_MODEL_OPTIONS.filter((option) => @@ -108,7 +116,7 @@ export function ModelSelector({ isNearTop, panelWidth }: ModelSelectorProps) { <div> <div className='px-2 py-1 font-medium text-[10px] text-muted-foreground uppercase'> - OpenAI + {translateWorkflowLabel(locale, 'OpenAI')} </div> <div className='space-y-0.5'> {COPILOT_RUNTIME_MODEL_OPTIONS.filter((option) => diff --git a/apps/tradinggoose/widgets/widgets/copilot/live-contexts.test.ts b/apps/tradinggoose/widgets/widgets/copilot/live-contexts.test.ts index 8a556c176..a892b8137 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/live-contexts.test.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/live-contexts.test.ts @@ -117,7 +117,7 @@ describe('buildImplicitCopilotContexts', () => { ]) }) - it('does not emit current context for draft-only review targets', () => { + it('does not emit current context for review-target-only payloads', () => { expect( buildImplicitCopilotContexts({ workspaceId: 'workspace-1', @@ -147,7 +147,7 @@ describe('buildCopilotEditableReviewTargets', () => { ).toEqual([]) }) - it('preserves saved and draft entity review targets', () => { + it('ignores review metadata even when it is forced into pair context', () => { expect( buildCopilotEditableReviewTargets({ pairContext: { @@ -159,36 +159,6 @@ describe('buildCopilotEditableReviewTargets', () => { }, } as any, }) - ).toEqual([ - { - entityKind: 'indicator', - entityId: null, - reviewSessionId: 'review-indicator-1', - draftSessionId: 'draft-indicator-1', - }, - ]) - }) - - it('keeps editable review targets separate from current entity ids', () => { - expect( - buildCopilotEditableReviewTargets({ - pairContext: { - skillId: 'skill-saved', - reviewTarget: { - reviewEntityKind: 'skill', - reviewEntityId: null, - reviewSessionId: 'review-draft-skill', - reviewDraftSessionId: 'draft-skill', - }, - } as any, - }) - ).toEqual([ - { - entityKind: 'skill', - entityId: null, - reviewSessionId: 'review-draft-skill', - draftSessionId: 'draft-skill', - }, - ]) + ).toEqual([]) }) }) diff --git a/apps/tradinggoose/widgets/widgets/copilot/live-contexts.ts b/apps/tradinggoose/widgets/widgets/copilot/live-contexts.ts index dd63c38f2..931f29ea1 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/live-contexts.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/live-contexts.ts @@ -1,4 +1,4 @@ -import { REVIEW_ENTITY_KINDS, type ReviewEntityKind } from '@/lib/copilot/review-sessions/types' +import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' import { normalizeOptionalString } from '@/lib/utils' import type { ChatContext } from '@/stores/copilot/types' import type { PairColorContext } from '@/stores/dashboard/pair-store' @@ -20,37 +20,6 @@ export type CopilotEditableReviewTarget = { draftSessionId: string | null } -type ActiveReviewTarget = { - entityKind: ReviewEntityKind - entityId: string | null - reviewSessionId: string | null - draftSessionId: string | null -} - -function readPairReviewTarget(pairContext?: PairColorContext | null): ActiveReviewTarget | null { - const reviewEntityKind = normalizeOptionalString(pairContext?.reviewTarget?.reviewEntityKind) - const reviewEntityId = normalizeOptionalString(pairContext?.reviewTarget?.reviewEntityId) ?? null - const reviewSessionId = - normalizeOptionalString(pairContext?.reviewTarget?.reviewSessionId) ?? null - const draftSessionId = - normalizeOptionalString(pairContext?.reviewTarget?.reviewDraftSessionId) ?? null - - if ( - !reviewEntityKind || - !REVIEW_ENTITY_KINDS.includes(reviewEntityKind as ReviewEntityKind) || - (!reviewEntityId && !reviewSessionId && !draftSessionId) - ) { - return null - } - - return { - entityKind: reviewEntityKind as ReviewEntityKind, - entityId: reviewEntityId, - reviewSessionId, - draftSessionId, - } -} - export function resolveCopilotWorkflowId( pairContext?: PairColorContext | null ): string | undefined { @@ -58,22 +27,11 @@ export function resolveCopilotWorkflowId( } export function buildCopilotEditableReviewTargets({ - pairContext, -}: Pick<BuildImplicitCopilotContextsOptions, 'pairContext'>): CopilotEditableReviewTarget[] { - const activeReviewTarget = readPairReviewTarget(pairContext) - - if (!activeReviewTarget || activeReviewTarget.entityKind === 'workflow') { - return [] - } - - return [ - { - entityKind: activeReviewTarget.entityKind, - entityId: activeReviewTarget.entityId, - reviewSessionId: activeReviewTarget.reviewSessionId, - draftSessionId: activeReviewTarget.draftSessionId, - }, - ] + pairContext: _pairContext, +}: { + pairContext?: PairColorContext | null +}): CopilotEditableReviewTarget[] { + return [] } export const buildImplicitCopilotContexts = ({ diff --git a/apps/tradinggoose/widgets/widgets/data_chart/components/header.tsx b/apps/tradinggoose/widgets/widgets/data_chart/components/header.tsx index 083a5f1a4..bc840c7c1 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/components/header.tsx +++ b/apps/tradinggoose/widgets/widgets/data_chart/components/header.tsx @@ -1,12 +1,18 @@ 'use client' -import type { DashboardWidgetDefinition } from '@/widgets/types' import type { PairColor } from '@/widgets/pair-colors' -import { DataChartProviderControls } from '@/widgets/widgets/data_chart/components/provider-controls' -import { DataChartListingControl } from '@/widgets/widgets/data_chart/components/listing-control' +import type { DashboardWidgetDefinition } from '@/widgets/types' import { DataChartChartControls } from '@/widgets/widgets/data_chart/components/chart-controls' -import type { DataChartWidgetParams, dataChartWidgetParams } from '@/widgets/widgets/data_chart/types' +import { DataChartListingControl } from '@/widgets/widgets/data_chart/components/listing-control' +import { + DataChartProviderControls, + DataChartRefreshControl, +} from '@/widgets/widgets/data_chart/components/provider-controls' import { resolveSeriesWindow } from '@/widgets/widgets/data_chart/series-window' +import type { + DataChartWidgetParams, + dataChartWidgetParams, +} from '@/widgets/widgets/data_chart/types' export const renderDataChartHeader: DashboardWidgetDefinition['renderHeader'] = ({ widget, @@ -26,8 +32,8 @@ export const renderDataChartHeader: DashboardWidgetDefinition['renderHeader'] = <DataChartProviderControls widgetKey={widgetKey} panelId={panelId} - workspaceId={context?.workspaceId} params={dataParams as DataChartWidgetParams} + workspaceId={context?.workspaceId} /> ), center: ( @@ -39,15 +45,22 @@ export const renderDataChartHeader: DashboardWidgetDefinition['renderHeader'] = /> ), right: ( - <DataChartChartControls - workspaceId={context?.workspaceId} - params={dataParams as DataChartWidgetParams} - interval={seriesWindow.interval} - allowedIntervals={seriesWindow.allowedIntervals} - supportsInterval={seriesWindow.supportsInterval} - panelId={panelId} - widgetKey={widgetKey} - /> + <> + <DataChartChartControls + workspaceId={context?.workspaceId} + params={dataParams as DataChartWidgetParams} + interval={seriesWindow.interval} + allowedIntervals={seriesWindow.allowedIntervals} + supportsInterval={seriesWindow.supportsInterval} + panelId={panelId} + widgetKey={widgetKey} + /> + <DataChartRefreshControl + providerId={dataParams.data?.provider} + panelId={panelId} + widgetKey={widgetKey} + /> + </> ), } } diff --git a/apps/tradinggoose/widgets/widgets/data_chart/components/provider-controls.tsx b/apps/tradinggoose/widgets/widgets/data_chart/components/provider-controls.tsx index 75ad7a36a..f36908c9c 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/components/provider-controls.tsx +++ b/apps/tradinggoose/widgets/widgets/data_chart/components/provider-controls.tsx @@ -1,289 +1,65 @@ 'use client' -import { useEffect, useMemo, useRef, useState } from 'react' -import { KeyRound, RefreshCw } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Label } from '@/components/ui/label' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Switch } from '@/components/ui/switch' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import type { SubBlockConfig } from '@/blocks/types' -import { getMarketProviderParamDefinitions } from '@/providers/market/providers' -import { MarketProviderSelector } from '@/widgets/widgets/components/market-provider-selector' -import { - widgetHeaderButtonGroupClassName, - widgetHeaderIconButtonClassName, -} from '@/widgets/widgets/components/widget-header-control' import { emitDataChartParamsChange } from '@/widgets/utils/chart-params' -import { ShortInput } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/short-input' +import { MarketProviderControls } from '@/widgets/widgets/components/market-provider-controls' +import { WidgetHeaderRefreshButton } from '@/widgets/widgets/components/widget-header-refresh-button' import { providerOptions } from '@/widgets/widgets/data_chart/options' -import type { - DataChartAuthParams, - DataChartDataParams, - DataChartWidgetParams, -} from '@/widgets/widgets/data_chart/types' -import { coerceProviderParams } from '@/widgets/widgets/data_chart/series-window' +import type { DataChartWidgetParams } from '@/widgets/widgets/data_chart/types' type DataChartProviderControlsProps = { widgetKey?: string panelId?: string - workspaceId?: string params: DataChartWidgetParams + workspaceId?: string } -type ProviderSettingsButtonProps = { +type RefreshButtonProps = { providerId?: string - providerParams?: Record<string, unknown> - authParams?: DataChartAuthParams - dataParams?: DataChartDataParams panelId?: string widgetKey?: string - workspaceId?: string } -export const DataChartProviderSettingsButton = ({ - providerId, - providerParams, - authParams, - dataParams, - panelId, - widgetKey, - workspaceId, -}: ProviderSettingsButtonProps) => { - const paramDefinitions = useMemo(() => { - if (!providerId) return [] - return getMarketProviderParamDefinitions(providerId, 'series').filter( - (definition) => - definition.required && - definition.visibility !== 'hidden' && - definition.visibility !== 'llm-only' - ) - }, [providerId]) - - const [settingsOpen, setSettingsOpen] = useState(false) - const paramValuesRef = useRef<Record<string, unknown>>({}) - const [inputValues, setInputValues] = useState<Record<string, string>>({}) - - useEffect(() => { - if (!settingsOpen) return - paramValuesRef.current = {} - setInputValues({}) - }, [settingsOpen]) - - const handleSaveProviderParams = () => { - if (!providerId) return - const nextProviderParamsInput = { - ...(providerParams ?? {}), - ...paramValuesRef.current, - } as Record<string, unknown> - delete nextProviderParamsInput.apiKey - delete nextProviderParamsInput.apiSecret - const sanitized = coerceProviderParams(providerId, nextProviderParamsInput) - const nextProviderParams = - sanitized && Object.keys(sanitized).length > 0 ? sanitized : undefined - const nextAuth: DataChartAuthParams | undefined = (() => { - const apiKey = - (paramValuesRef.current.apiKey as string | undefined) ?? authParams?.apiKey - const apiSecret = - (paramValuesRef.current.apiSecret as string | undefined) ?? authParams?.apiSecret - return apiKey || apiSecret ? { apiKey, apiSecret } : undefined - })() - const { ...nextDataBase } = (dataParams ?? {}) as Record<string, unknown> - emitDataChartParamsChange({ - params: { - data: { - ...nextDataBase, - providerParams: nextProviderParams, - auth: nextAuth, - }, - }, - panelId, - widgetKey, - }) - setSettingsOpen(false) - } - - const hasRequiredParams = paramDefinitions.length > 0 - const handleParamChange = (id: string, value: unknown) => { - if (typeof value === 'string' && value.trim() === '') { - delete paramValuesRef.current[id] - return - } - paramValuesRef.current[id] = value - } - - if (!hasRequiredParams) return null - +export const DataChartRefreshControl = ({ providerId, panelId, widgetKey }: RefreshButtonProps) => { return ( - <Popover open={settingsOpen} onOpenChange={setSettingsOpen}> - <Tooltip> - <TooltipTrigger asChild> - <PopoverTrigger asChild> - <button - type='button' - className={widgetHeaderIconButtonClassName()} - disabled={!providerId} - > - <KeyRound className='h-3.5 w-3.5' /> - <span className='sr-only'>Provider settings</span> - </button> - </PopoverTrigger> - </TooltipTrigger> - <TooltipContent side='top'>Provider settings</TooltipContent> - </Tooltip> - <PopoverContent className='w-72 space-y-3 p-4'> - <div className='space-y-1'> - <p className='font-medium text-sm'>Provider settings</p> - <p className='text-muted-foreground text-xs'>Save credentials for this widget.</p> - </div> - <div className='space-y-3'> - {paramDefinitions.map((definition) => { - const inputId = `provider-param-${providerId ?? 'unknown'}-${definition.id}` - const isPassword = definition.password || definition.id.toLowerCase().includes('secret') - const isAuthField = definition.id === 'apiKey' || definition.id === 'apiSecret' - const savedValue = isAuthField - ? authParams?.[definition.id] - : providerParams?.[definition.id] - const resolvedValue = savedValue ?? definition.defaultValue - const selectValue = - typeof resolvedValue === 'string' || typeof resolvedValue === 'number' - ? String(resolvedValue) - : undefined - const inputValue = - typeof resolvedValue === 'string' || typeof resolvedValue === 'number' - ? String(resolvedValue) - : typeof resolvedValue === 'object' && resolvedValue !== null - ? JSON.stringify(resolvedValue) - : undefined - const booleanValue = - typeof resolvedValue === 'boolean' - ? resolvedValue - : typeof resolvedValue === 'string' - ? resolvedValue.toLowerCase() === 'true' - : false - const controlledValue = inputValues[definition.id] ?? (inputValue ?? '') - const shortInputConfig: SubBlockConfig = { - id: definition.id, - title: definition.title ?? definition.id, - type: 'short-input', - inputType: definition.type === 'number' ? 'number' : 'text', - placeholder: definition.placeholder, - min: definition.min, - max: definition.max, - step: definition.step, - integer: definition.integer, - connectionDroppable: false, - } - - if (definition.inputType === 'switch' || definition.type === 'boolean') { - return ( - <div - key={`${providerId ?? 'unknown'}-${definition.id}`} - className='flex items-center justify-between gap-2' - > - <Label htmlFor={inputId} className='text-xs'> - {definition.title ?? definition.id} - </Label> - <Switch - id={inputId} - defaultChecked={booleanValue} - onCheckedChange={(checked) => handleParamChange(definition.id, checked)} - /> - </div> - ) - } - - if (definition.options?.length) { - return ( - <div key={`${providerId ?? 'unknown'}-${definition.id}`} className='space-y-1'> - <Label htmlFor={inputId} className='text-xs'> - {definition.title ?? definition.id} - </Label> - <Select - defaultValue={selectValue} - onValueChange={(nextValue) => handleParamChange(definition.id, nextValue)} - > - <SelectTrigger id={inputId}> - <SelectValue placeholder={definition.placeholder ?? 'Select'} /> - </SelectTrigger> - <SelectContent> - {definition.options.map((option) => ( - <SelectItem key={option.id} value={option.id}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - ) - } - - return ( - <div key={`${providerId ?? 'unknown'}-${definition.id}`} className='space-y-1'> - <Label htmlFor={inputId} className='text-xs'> - {definition.title ?? definition.id} - </Label> - <ShortInput - blockId={`provider-${providerId ?? 'unknown'}`} - subBlockId={definition.id} - inputId={inputId} - isConnecting={false} - config={shortInputConfig} - value={controlledValue} - onChange={(value) => { - setInputValues((current) => ({ ...current, [definition.id]: value })) - handleParamChange(definition.id, value) - }} - placeholder={definition.placeholder} - password={isPassword} - workspaceId={workspaceId} - enableTags={false} - /> - </div> - ) - })} - </div> - <div className='flex justify-end gap-2'> - <Button size='sm' variant='outline' onClick={() => setSettingsOpen(false)}> - Cancel - </Button> - <Button size='sm' onClick={handleSaveProviderParams}> - Save - </Button> - </div> - </PopoverContent> - </Popover> + <WidgetHeaderRefreshButton + disabled={!providerId} + onClick={() => { + if (!providerId) return + emitDataChartParamsChange({ + params: { runtime: { refreshAt: Date.now() } }, + panelId, + widgetKey, + }) + }} + /> ) } -type ProviderSelectorProps = { - providerId?: string - dataParams?: DataChartDataParams - viewParams?: DataChartWidgetParams['view'] - panelId?: string - widgetKey?: string -} - -export const DataChartProviderSelector = ({ - providerId, - dataParams, - viewParams, - panelId, +export const DataChartProviderControls = ({ widgetKey, -}: ProviderSelectorProps) => { + panelId, + params, + workspaceId, +}: DataChartProviderControlsProps) => { + const providerId = params.data?.provider + const providerParams = params.data?.providerParams ?? {} + const authParams = params.data?.auth const handleProviderChange = (nextProvider: string) => { if (!nextProvider || nextProvider === providerId) return const { window: _window, fallbackWindow: _fallbackWindow, + auth: _auth, + providerParams: _providerParams, ...nextDataBase - } = (dataParams ?? {}) as Record<string, unknown> + } = (params.data ?? {}) as Record<string, unknown> const nextData = { ...nextDataBase, provider: nextProvider } - const nextView = { ...(viewParams ?? {}) } as Record<string, unknown> - delete nextView.rangePresetId + const { rangePresetId: _rangePresetId, ...nextView } = (params.view ?? {}) as Record< + string, + unknown + > emitDataChartParamsChange({ params: { @@ -296,75 +72,27 @@ export const DataChartProviderSelector = ({ } return ( - <MarketProviderSelector - value={providerId ?? ''} + <MarketProviderControls + value={providerId} options={providerOptions} onChange={handleProviderChange} + providerParams={providerParams} + authParams={authParams} + workspaceId={workspaceId} + onSettingsSave={({ providerParams: nextProviderParams, auth }) => { + const { ...nextDataBase } = (params.data ?? {}) as Record<string, unknown> + emitDataChartParamsChange({ + params: { + data: { + ...nextDataBase, + providerParams: nextProviderParams, + auth, + }, + }, + panelId, + widgetKey, + }) + }} /> ) } - -type RefreshButtonProps = { - providerId?: string - panelId?: string - widgetKey?: string -} - -export const DataChartRefreshButton = ({ providerId, panelId, widgetKey }: RefreshButtonProps) => { - if (!providerId) return null - - return ( - <Tooltip> - <TooltipTrigger asChild> - <button - type='button' - className={widgetHeaderIconButtonClassName()} - onClick={() => - emitDataChartParamsChange({ - params: { runtime: { refreshAt: Date.now() } }, - panelId, - widgetKey, - }) - } - > - <RefreshCw className='h-3.5 w-3.5' /> - <span className='sr-only'>Refresh data</span> - </button> - </TooltipTrigger> - <TooltipContent side='top'>Refresh data</TooltipContent> - </Tooltip> - ) -} - -export const DataChartProviderControls = ({ - widgetKey, - panelId, - workspaceId, - params, -}: DataChartProviderControlsProps) => { - const providerId = params.data?.provider - const providerParams = params.data?.providerParams ?? {} - const authParams = params.data?.auth - - return ( - <div className={widgetHeaderButtonGroupClassName()}> - <DataChartProviderSettingsButton - providerId={providerId} - providerParams={providerParams} - authParams={authParams} - dataParams={params.data} - panelId={panelId} - widgetKey={widgetKey} - workspaceId={workspaceId} - /> - <DataChartProviderSelector - providerId={providerId} - dataParams={params.data} - viewParams={params.view} - panelId={panelId} - widgetKey={widgetKey} - /> - <DataChartRefreshButton providerId={providerId} panelId={panelId} widgetKey={widgetKey} /> - </div> - ) -} diff --git a/apps/tradinggoose/widgets/widgets/data_chart/hooks/use-chart-data-loader.ts b/apps/tradinggoose/widgets/widgets/data_chart/hooks/use-chart-data-loader.ts index 0596c8447..5f4d95d29 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/hooks/use-chart-data-loader.ts +++ b/apps/tradinggoose/widgets/widgets/data_chart/hooks/use-chart-data-loader.ts @@ -3,7 +3,8 @@ import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'react' import type { IChartApi, ISeriesApi } from 'lightweight-charts' import type { Socket } from 'socket.io-client' -import type { ListingIdentity } from '@/lib/listing/identity' +import { stableStringifyJsonValue } from '@/lib/json/stable' +import { getListingIdentityKey, type ListingIdentity } from '@/lib/listing/identity' import { getMarketSeriesCapabilities } from '@/providers/market/providers' import type { MarketInterval, @@ -142,7 +143,7 @@ export const useChartDataLoader = ({ ) const listingSignature = useMemo(() => { if (!listing) return null - return `${listing.listing_type}|${listing.listing_id}|${listing.base_id}|${listing.quote_id}` + return getListingIdentityKey(listing) }, [listing]) const rangeKey = seriesWindow.windowKey ?? 'none' const rescaleKey = useMemo( @@ -456,9 +457,7 @@ export const useChartDataLoader = ({ } } - const listingLoadKey = listing - ? `${listing.listing_type}|${listing.listing_id}|${listing.base_id}|${listing.quote_id}` - : 'none' + const listingLoadKey = listing ? getListingIdentityKey(listing) : 'none' const seriesLoadKey = [ workspaceId ?? 'none', providerId ?? 'none', @@ -466,8 +465,8 @@ export const useChartDataLoader = ({ requestInterval ?? 'none', normalizationMode ?? 'none', seriesWindow.windowKey ?? 'none', - JSON.stringify(providerParams ?? null), - JSON.stringify(authParams ?? null), + stableStringifyJsonValue(providerParams ?? null), + stableStringifyJsonValue(authParams ?? null), refreshAt ?? 'none', ].join('|') const previousSeriesLoadKey = lastSeriesLoadKeyRef.current diff --git a/apps/tradinggoose/widgets/widgets/data_chart/hooks/use-indicator-sync.ts b/apps/tradinggoose/widgets/widgets/data_chart/hooks/use-indicator-sync.ts index c47653b96..dd5c2bb67 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/hooks/use-indicator-sync.ts +++ b/apps/tradinggoose/widgets/widgets/data_chart/hooks/use-indicator-sync.ts @@ -19,10 +19,12 @@ import { type SeriesMarker, } from 'lightweight-charts' import { getStableVibrantColorWithOffset } from '@/lib/colors' +import { executeBrowserPineIndicator } from '@/lib/indicators/browser-execution' import { DEFAULT_INDICATOR_MAP } from '@/lib/indicators/default' import { buildInputsMapFromMeta } from '@/lib/indicators/input-meta' import type { IndicatorOptions, + InputMetaMap, NormalizedPineMarker, NormalizedPineOutput, } from '@/lib/indicators/types' @@ -43,53 +45,10 @@ const DEFAULT_PANE_HEIGHT_PX = 100 const EXECUTION_DEBOUNCE_MS = 0 const MAX_EXECUTION_CHUNK_BARS = 1200 const EXECUTION_CONTEXT_BARS = 300 -const MAX_INDICATOR_EXECUTION_RETRIES = 3 -const INDICATOR_EXECUTION_RETRY_BASE_MS = 500 -const INDICATOR_EXECUTION_RETRY_MAX_MS = 4000 const DEFAULT_PINE_LINE_WIDTH = 1 type MainSeries = ISeriesApi<'Candlestick'> | ISeriesApi<'Bar'> | ISeriesApi<'Area'> -const isRetryableIndicatorExecutionStatus = (status: number) => - status === 429 || status === 502 || status === 503 || status === 504 - -const waitForIndicatorRetry = (delayMs: number, signal: AbortSignal) => - new Promise<void>((resolve, reject) => { - if (signal.aborted) { - const abortError = new Error('Aborted') - abortError.name = 'AbortError' - reject(abortError) - return - } - - const timeoutId = window.setTimeout(() => { - signal.removeEventListener('abort', onAbort) - resolve() - }, delayMs) - - const onAbort = () => { - window.clearTimeout(timeoutId) - const abortError = new Error('Aborted') - abortError.name = 'AbortError' - reject(abortError) - } - - signal.addEventListener('abort', onAbort, { once: true }) - }) - -const getIndicatorExecutionRetryDelayMs = (attempt: number, response?: Response) => { - const retryAfter = response?.headers.get('Retry-After') - if (retryAfter) { - const parsedRetryAfter = Number(retryAfter) - if (Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0) { - return Math.min(parsedRetryAfter * 1000, INDICATOR_EXECUTION_RETRY_MAX_MS) - } - } - - const delayMs = INDICATOR_EXECUTION_RETRY_BASE_MS * 2 ** attempt - return Math.min(delayMs + Math.floor(Math.random() * 100), INDICATOR_EXECUTION_RETRY_MAX_MS) -} - const isPriceMarkerPosition = ( position: NormalizedPineMarker['position'] ): position is 'atPriceTop' | 'atPriceBottom' | 'atPriceMiddle' => @@ -248,6 +207,8 @@ type ExecuteResult = { type ExecutionInput = { id: string + pineCode: string + inputMeta?: InputMetaMap | null inputsMap: Record<string, unknown> accumulationBase: string } @@ -926,6 +887,8 @@ export const useIndicatorSync = ({ const defaultIndicator = DEFAULT_INDICATOR_MAP.get(id) if (!indicator && !defaultIndicator) return const inputMeta = indicator?.inputMeta ?? defaultIndicator?.inputMeta + const pineCode = indicator?.pineCode ?? defaultIndicator?.pineCode ?? '' + if (!pineCode.trim()) return const inputsMap = buildInputsMapFromMeta( inputMeta ?? undefined, indicatorRefMap.get(id)?.inputs @@ -934,6 +897,8 @@ export const useIndicatorSync = ({ const indicatorVersion = indicator?.updatedAt ?? indicator?.createdAt ?? 'default' indicatorInputs.push({ id, + pineCode, + inputMeta, inputsMap, accumulationBase: `${id}:${indicatorVersion}:${inputsHash}`, }) @@ -991,94 +956,57 @@ export const useIndicatorSync = ({ const resultById = new Map<string, ExecuteResult>() const executionErrorById = new Map<string, string>() - // Execute groups serially so reconnects or chart refreshes do not burst the - // per-user execution lease with parallel requests. + // Execute chunks serially so chart refreshes do not start multiple PineTS + // batches over overlapping bar windows at the same time. for (const group of executionGroups.values()) { if (controller.signal.aborted || runId !== runIdRef.current) return - const marketSeries = { - listing: listing ?? undefined, - bars: group.bars.map((bar) => ({ - timeStamp: new Date(bar.openTime).toISOString(), - open: bar.open, - high: bar.high, - low: bar.low, - close: bar.close, - volume: bar.volume, - turnover: bar.turnover, - })), - } - - for (let attempt = 0; attempt <= MAX_INDICATOR_EXECUTION_RETRIES; attempt += 1) { - if (controller.signal.aborted || runId !== runIdRef.current) return - - try { - const response = await fetch('/api/indicators/execute', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - signal: controller.signal, - body: JSON.stringify({ - workspaceId, - indicatorIds: group.inputs.map((item) => item.id), - marketSeries, - inputsMapById: group.inputs.reduce<Record<string, Record<string, unknown>>>( - (acc, item) => { - acc[item.id] = item.inputsMap - return acc - }, - {} - ), + const results = await Promise.all( + group.inputs.map(async (item): Promise<ExecuteResult | null> => { + if (controller.signal.aborted || runId !== runIdRef.current) return null + + try { + const { output, warnings } = await executeBrowserPineIndicator({ + barsMs: group.bars, + pineCode: item.pineCode, + inputsMap: item.inputsMap, + inputMeta: item.inputMeta, + listing, interval: interval ?? undefined, - intervalMs: dataContext.intervalMs ?? undefined, - }), - }) - - const payload = await response.json().catch(() => ({})) - if (response.ok && payload?.success && Array.isArray(payload?.data)) { - ;(payload.data as ExecuteResult[]).forEach((result) => { - resultById.set(result.indicatorId, result) }) - break - } - - const errorMessage = payload?.error || 'Failed to execute indicators' - const isRetryableResponse = - isRetryableIndicatorExecutionStatus(response.status) && - attempt < MAX_INDICATOR_EXECUTION_RETRIES - if (isRetryableResponse) { - const delayMs = getIndicatorExecutionRetryDelayMs(attempt, response) - await waitForIndicatorRetry(delayMs, controller.signal) - continue - } - group.inputs.forEach((item) => { - executionErrorById.set(item.id, errorMessage) - }) - warnOnce(errorMessage) - break - } catch (error) { - if ((error as Error).name === 'AbortError') return - - if (attempt < MAX_INDICATOR_EXECUTION_RETRIES) { - const delayMs = getIndicatorExecutionRetryDelayMs(attempt) - try { - await waitForIndicatorRetry(delayMs, controller.signal) - } catch (waitError) { - if ((waitError as Error).name === 'AbortError') return - throw waitError + return { + indicatorId: item.id, + output, + warnings, + unsupported: output.unsupported, + counts: { + plots: output.series.length, + markers: output.markers.length, + triggers: output.triggers.length, + }, + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to execute indicators' + return { + indicatorId: item.id, + output: null, + warnings: [], + unsupported: { plots: [], styles: [] }, + counts: { plots: 0, markers: 0, triggers: 0 }, + executionError: { message: errorMessage, code: 'runtime_error' }, } - continue } + }) + ) - const errorMessage = - error instanceof Error ? error.message : 'Failed to execute indicators' - group.inputs.forEach((item) => { - executionErrorById.set(item.id, errorMessage) - }) - warnOnce(errorMessage) - break - } - } + if (controller.signal.aborted || runId !== runIdRef.current) return + + results.forEach((result) => { + if (!result) return + resultById.set(result.indicatorId, result) + }) } if (controller.signal.aborted || runId !== runIdRef.current) { diff --git a/apps/tradinggoose/widgets/widgets/data_chart/index.tsx b/apps/tradinggoose/widgets/widgets/data_chart/index.tsx index aa5f16293..ddb89e56d 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/index.tsx +++ b/apps/tradinggoose/widgets/widgets/data_chart/index.tsx @@ -9,7 +9,7 @@ export const dataChartWidget: DashboardWidgetDefinition = { key: 'data_chart', title: 'Data Chart', icon: CandlestickChart, - category: 'utility', + category: 'trading', description: 'Visualize OHLCV market data.', component: (props) => <DataChartWidgetBody {...props} />, renderHeader: renderDataChartHeader, diff --git a/apps/tradinggoose/widgets/widgets/data_chart/options.ts b/apps/tradinggoose/widgets/widgets/data_chart/options.ts index 2385033a5..18c82b917 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/options.ts +++ b/apps/tradinggoose/widgets/widgets/data_chart/options.ts @@ -9,17 +9,36 @@ import { } from '@/components/icons/icons' import type { DataChartCandleType } from '@/widgets/widgets/data_chart/types' -export const providerOptions = getMarketProviderOptionsByKind('series') +export const getSeriesMarketProviderOptions = () => getMarketProviderOptionsByKind('series') + +export const providerOptions = getSeriesMarketProviderOptions() + +export const resolveSeriesMarketProviderId = ( + provider: unknown, + options = getSeriesMarketProviderOptions() +) => { + const providerId = typeof provider === 'string' ? provider.trim() : '' + if (providerId && options.some((option) => option.id === providerId)) return providerId + return options[0]?.id ?? '' +} + +export const resolveConfiguredSeriesMarketProviderId = ( + provider: unknown, + options = getSeriesMarketProviderOptions() +) => { + const providerId = typeof provider === 'string' ? provider.trim() : '' + return providerId && options.some((option) => option.id === providerId) ? providerId : '' +} export const CANDLE_TYPE_OPTIONS: Array<{ id: DataChartCandleType label: string icon: typeof BarSolid }> = [ - { id: 'candle_solid', label: 'Solid', icon: BarSolid }, - { id: 'candle_stroke', label: 'Hollow', icon: BarHollow }, - { id: 'candle_up_stroke', label: 'Up Hollow', icon: BarUpHollow }, - { id: 'candle_down_stroke', label: 'Down Hollow', icon: BarDownHollow }, - { id: 'ohlc', label: 'Bar Stroke', icon: BarStroke }, - { id: 'area', label: 'Area', icon: AreaChartIcon }, - ] + { id: 'candle_solid', label: 'Solid', icon: BarSolid }, + { id: 'candle_stroke', label: 'Hollow', icon: BarHollow }, + { id: 'candle_up_stroke', label: 'Up Hollow', icon: BarUpHollow }, + { id: 'candle_down_stroke', label: 'Down Hollow', icon: BarDownHollow }, + { id: 'ohlc', label: 'Bar Stroke', icon: BarStroke }, + { id: 'area', label: 'Area', icon: AreaChartIcon }, +] diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.test.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.test.tsx index d82853330..a1ebd6304 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.test.tsx @@ -92,7 +92,9 @@ describe('Custom Tool Editor header controls', () => { root.render(header?.right as ReactNode) }) - const buttons = Array.from(container.querySelectorAll('button')) - expect(buttons[0]?.hasAttribute('disabled')).toBe(true) + const exportButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Export custom tool') + ) + expect(exportButton).toBeDisabled() }) }) diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx index d67cf047a..c6af2bc0b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx @@ -5,7 +5,6 @@ import { Download, Save, SquareTerminal } from 'lucide-react' import { Button } from '@/components/ui/button' import { LoadingAgent } from '@/components/ui/loading-agent' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' import { useCustomTools } from '@/hooks/queries/custom-tools' import { useCustomToolsStore } from '@/stores/custom-tools/store' import type { CustomToolDefinition } from '@/stores/custom-tools/types' @@ -26,10 +25,7 @@ import { resolveCustomToolId, } from '@/widgets/widgets/_shared/custom_tool/utils' import { CustomToolDropdown } from '@/widgets/widgets/components/custom-tool-dropdown' -import { - widgetHeaderButtonGroupClassName, - widgetHeaderControlClassName, -} from '@/widgets/widgets/components/widget-header-control' +import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' import { CustomToolEditor, type CustomToolEditorSection, @@ -132,13 +128,15 @@ function EditorCustomToolWidgetBody({ const paramsCustomToolId = resolveCustomToolId({ params }) const requestedCustomToolId = isLinkedToColorPair - ? (pairContext?.customToolId ?? paramsCustomToolId) + ? (pairContext?.customToolId ?? null) : paramsCustomToolId const normalizedRequestedCustomToolId = requestedCustomToolId?.trim() ?? '' const hasRequestedTool = normalizedRequestedCustomToolId.length > 0 && tools.some((tool) => tool.id === normalizedRequestedCustomToolId) - const selectedToolId = hasRequestedTool ? normalizedRequestedCustomToolId : (tools[0]?.id ?? null) + const selectedToolId = hasRequestedTool + ? normalizedRequestedCustomToolId + : (isLinkedToColorPair ? null : (tools[0]?.id ?? null)) useCustomToolSelectionPersistence({ onWidgetParamsChange, @@ -239,7 +237,17 @@ function EditorCustomToolWidgetBody({ } if (!selectedToolId) { - return <WidgetStateMessage message='No custom tools yet.' /> + return ( + <WidgetStateMessage + message={ + isLinkedToColorPair + ? normalizedRequestedCustomToolId.length > 0 + ? 'Custom tool not found.' + : 'This color has no shared custom tool selected yet.' + : 'No custom tools yet.' + } + /> + ) } if (!selectedTool) { @@ -297,7 +305,7 @@ function CustomToolEditorSelector({ const setPairContext = useSetPairColorContext() const selectedToolId = isLinkedToColorPair - ? resolveCustomToolId({ pairContext, params }) + ? resolveCustomToolId({ pairContext }) : resolveCustomToolId({ params }) const handleCustomToolChange = (customToolId: string | null) => { @@ -325,7 +333,12 @@ function CustomToolEditorSelector({ ) } -function CustomToolEditorSectionTabs({ +const CUSTOM_TOOL_EDITOR_SECTIONS: Array<{ id: CustomToolEditorSection; label: string }> = [ + { id: 'schema', label: 'Config' }, + { id: 'code', label: 'Code' }, +] + +function CustomToolEditorSectionSwitch({ panelId, params, pairColor = 'gray', @@ -341,7 +354,7 @@ function CustomToolEditorSectionTabs({ const pairContext = usePairColorContext(resolvedPairColor) const [activeSection, setActiveSection] = useState<CustomToolEditorSection>('schema') const customToolId = isLinkedToColorPair - ? resolveCustomToolId({ pairContext, params }) + ? resolveCustomToolId({ pairContext }) : resolveCustomToolId({ params }) const isDisabled = !customToolId || !panelId @@ -368,40 +381,26 @@ function CustomToolEditorSectionTabs({ } return ( - <> - <button - type='button' - disabled={isDisabled} - className={widgetHeaderControlClassName( - cn( - 'min-w-[72px] justify-center px-2', - activeSection === 'schema' - ? 'border-border bg-card text-foreground' - : 'text-muted-foreground' - ) - )} - onClick={() => selectSection('schema')} - aria-pressed={activeSection === 'schema'} - > - Config - </button> - <button - type='button' - disabled={isDisabled} - className={widgetHeaderControlClassName( - cn( - 'min-w-[72px] justify-center px-2', - activeSection === 'code' - ? 'border-border bg-card text-foreground' - : 'text-muted-foreground' - ) - )} - onClick={() => selectSection('code')} - aria-pressed={activeSection === 'code'} - > - Code - </button> - </> + <div className='flex h-7 items-center gap-1 rounded-sm border border-border/70 bg-card/60 p-1'> + {CUSTOM_TOOL_EDITOR_SECTIONS.map((section) => { + const isSelected = section.id === activeSection + + return ( + <Button + key={section.id} + type='button' + variant={isSelected ? 'default' : 'ghost'} + size='sm' + className='h-5 min-w-14 rounded-xs px-3 text-sm' + disabled={isDisabled} + onClick={() => selectSection(section.id)} + aria-pressed={isSelected} + > + {section.label} + </Button> + ) + })} + </div> ) } @@ -423,7 +422,7 @@ function CustomToolEditorSaveButton({ const pairContext = usePairColorContext(resolvedPairColor) const resolvedCustomToolId = isLinkedToColorPair - ? (pairContext?.customToolId ?? customToolId ?? null) + ? (pairContext?.customToolId ?? null) : (customToolId ?? null) const saveDisabled = !workspaceId || !resolvedCustomToolId || !panelId @@ -473,7 +472,7 @@ function CustomToolEditorExportButton({ const pairContext = usePairColorContext(resolvedPairColor) const resolvedCustomToolId = isLinkedToColorPair - ? (pairContext?.customToolId ?? customToolId ?? null) + ? (pairContext?.customToolId ?? null) : (customToolId ?? null) const exportDisabled = !workspaceId || !resolvedCustomToolId || !panelId @@ -534,7 +533,7 @@ export const editorCustomToolWidget: DashboardWidgetDefinition = { ), right: ( <div className={widgetHeaderButtonGroupClassName()}> - <CustomToolEditorSectionTabs + <CustomToolEditorSectionSwitch panelId={panelId} params={ widget?.params && typeof widget.params === 'object' diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/components/indicator-editor-header.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/components/indicator-editor-header.tsx index febca9fcf..676749778 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/components/indicator-editor-header.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/components/indicator-editor-header.tsx @@ -36,7 +36,7 @@ export function IndicatorEditorSelector({ const setPairContext = useSetPairColorContext() const resolvedIndicatorId = isLinkedToColorPair - ? (pairContext?.indicatorId ?? indicatorId ?? null) + ? (pairContext?.indicatorId ?? null) : (indicatorId ?? null) const handleIndicatorChange = (ids: string[]) => { @@ -107,7 +107,7 @@ export function IndicatorEditorExportButton({ const [isDirty, setIsDirty] = useState(true) const resolvedIndicatorId = isLinkedToColorPair - ? (pairContext?.indicatorId ?? indicatorId ?? null) + ? (pairContext?.indicatorId ?? null) : (indicatorId ?? null) const indicator = useIndicatorsStore((state) => workspaceId && resolvedIndicatorId @@ -185,7 +185,7 @@ export function IndicatorEditorSaveButton({ const pairContext = usePairColorContext(resolvedPairColor) const resolvedIndicatorId = isLinkedToColorPair - ? (pairContext?.indicatorId ?? indicatorId ?? null) + ? (pairContext?.indicatorId ?? null) : (indicatorId ?? null) const saveDisabled = !workspaceId || !resolvedIndicatorId @@ -231,7 +231,7 @@ export function IndicatorEditorVerifyButton({ const pairContext = usePairColorContext(resolvedPairColor) const resolvedIndicatorId = isLinkedToColorPair - ? (pairContext?.indicatorId ?? indicatorId ?? null) + ? (pairContext?.indicatorId ?? null) : (indicatorId ?? null) const verifyDisabled = !workspaceId || !resolvedIndicatorId diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx index fb8fb6022..c0f3a8794 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx @@ -31,7 +31,7 @@ export function EditorIndicatorWidgetBody({ const paramsIndicatorId = getIndicatorIdFromParams(params) const requestedIndicatorId = isLinkedToColorPair - ? (pairContext?.indicatorId ?? paramsIndicatorId) + ? (pairContext?.indicatorId ?? null) : paramsIndicatorId const workspaceIndicators = workspaceId @@ -43,7 +43,7 @@ export function EditorIndicatorWidgetBody({ workspaceIndicators.some((indicator) => indicator.id === normalizedRequestedIndicatorId) const indicatorId = hasRequestedIndicator ? normalizedRequestedIndicatorId - : (workspaceIndicators[0]?.id ?? null) + : (isLinkedToColorPair ? null : (workspaceIndicators[0]?.id ?? null)) const indicator = indicatorId ? (workspaceIndicators.find((candidate) => candidate.id === indicatorId) ?? null) : null @@ -132,7 +132,17 @@ export function EditorIndicatorWidgetBody({ } if (!indicatorId) { - return <WidgetStateMessage message='Select an indicator to edit.' /> + return ( + <WidgetStateMessage + message={ + isLinkedToColorPair + ? normalizedRequestedIndicatorId.length > 0 + ? 'Indicator not found.' + : 'This color has no shared indicator selected yet.' + : 'Select an indicator to edit.' + } + /> + ) } if (!indicator) { diff --git a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx index 28f1bba58..80f159219 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx @@ -9,6 +9,7 @@ import { type Dispatch, type SetStateAction, } from 'react' +import { useLocale } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' import { ENTITY_KIND_MCP_SERVER, type ReviewTargetDescriptor } from '@/lib/copilot/review-sessions/types' import { @@ -36,25 +37,30 @@ import { type EntityEditorShellConfig, } from '@/widgets/widgets/components/entity-editor-shell' import { useGuardedUndoRedo } from '@/widgets/widgets/entity_review/use-guarded-undo-redo' +import { getPublicCopy, formatTemplate } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' type EditorMcpWidgetBodyProps = WidgetComponentProps -const getServerName = (server?: Pick<McpServerWithStatus, 'name'> | null) => - server?.name?.trim() || 'Unnamed server' +const getServerName = (server?: Pick<McpServerWithStatus, 'name'> | null, fallback = 'Unnamed server') => + server?.name?.trim() || fallback -const formatRelativeTime = (dateString?: string) => { +const formatRelativeTime = (dateString: string | undefined, locale: LocaleCode) => { if (!dateString) return null const date = new Date(dateString) const now = new Date() const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) - - if (diffInSeconds < 60) return 'just now' - if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago` - if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago` - if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago` - if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 604800)}w ago` - if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)}mo ago` - return `${Math.floor(diffInSeconds / 31536000)}y ago` + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) + + if (diffInSeconds < 60) return formatter.format(0, 'second') + if (diffInSeconds < 3600) return formatter.format(-Math.floor(diffInSeconds / 60), 'minute') + if (diffInSeconds < 86400) return formatter.format(-Math.floor(diffInSeconds / 3600), 'hour') + if (diffInSeconds < 604800) return formatter.format(-Math.floor(diffInSeconds / 86400), 'day') + if (diffInSeconds < 2592000) + return formatter.format(-Math.floor(diffInSeconds / 604800), 'week') + if (diffInSeconds < 31536000) + return formatter.format(-Math.floor(diffInSeconds / 2592000), 'month') + return formatter.format(-Math.floor(diffInSeconds / 31536000), 'year') } const getStatusClassName = (status?: McpServerWithStatus['connectionStatus'] | 'draft') => { @@ -73,14 +79,24 @@ const getStatusClassName = (status?: McpServerWithStatus['connectionStatus'] | ' return 'border-border bg-muted text-muted-foreground' } -const getStatusLabel = (status?: McpServerWithStatus['connectionStatus'] | 'draft') => { - if (status === 'connected') return 'Connected' - if (status === 'error') return 'Error' - if (status === 'draft') return 'Draft' - return 'Disconnected' +const getStatusLabel = ( + status: McpServerWithStatus['connectionStatus'] | 'draft' | undefined, + copy: Pick< + ReturnType<typeof getPublicCopy>['workspace']['widgets']['mcpEditor'], + 'connected' | 'error' | 'draft' | 'disconnected' + > +) => { + if (status === 'connected') return copy.connected + if (status === 'error') return copy.error + if (status === 'draft') return copy.draft + return copy.disconnected } -const refreshServerApi = async (serverId: string, workspaceId: string) => { +const refreshServerApi = async ( + serverId: string, + workspaceId: string, + fallbackMessage: string +) => { const response = await fetch( `/api/mcp/servers/${encodeURIComponent(serverId)}/refresh?workspaceId=${encodeURIComponent( workspaceId @@ -90,28 +106,37 @@ const refreshServerApi = async (serverId: string, workspaceId: string) => { const data = await response.json().catch(() => ({})) if (!response.ok) { - throw new Error(data?.error || `Failed to refresh server ${serverId}`) + throw new Error(data?.error || fallbackMessage) } return data } -const MCP_SHELL_CONFIG: EntityEditorShellConfig = { +const MCP_SHELL_BASE_CONFIG: Omit< + EntityEditorShellConfig, + 'noWorkspaceMessage' | 'noSelectionMessage' +> = { entityKind: ENTITY_KIND_MCP_SERVER, fallbackWidgetKey: 'editor_mcp', legacyIdKey: 'mcpServerId', buildWidgetParams: buildPersistedReviewParams, buildPairContext: buildPersistedPairContext, readEntitySelectionState, - noWorkspaceMessage: 'Select a workspace to edit MCP servers.', - noSelectionMessage: 'Select an MCP server to edit.', } export function EditorMcpWidgetBody(props: EditorMcpWidgetBodyProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.mcpEditor + const shellConfig: EntityEditorShellConfig = { + ...MCP_SHELL_BASE_CONFIG, + noWorkspaceMessage: copy.selectWorkspaceToEdit, + noSelectionMessage: copy.selectServerToEdit, + } + return ( <EntityEditorShell {...props} - config={MCP_SHELL_CONFIG} + config={shellConfig} useSelectionPersistence={({ resolvedPairColor, isLinkedToColorPair, @@ -175,6 +200,8 @@ function McpEditorSession({ descriptor: ReviewTargetDescriptor onReviewTargetChange: (descriptor: ReviewTargetDescriptor | null) => void }) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.mcpEditor const { doc, isLoading, error, undo, redo, runtime, canUndo, canRedo } = useEntitySession() const [saveError, setSaveError] = useState<string | null>(null) const { @@ -315,30 +342,35 @@ function McpEditorSession({ if (!workspaceId || !descriptor.entityId || !formDataState.url?.trim()) return await testConnection({ - name: formDataState.name.trim() || getServerName(selectedServer), + name: formDataState.name.trim() || getServerName(selectedServer, copy.unnamedServer), transport: formDataState.transport, url: formDataState.url, headers: createMcpSavePayload(formDataState).headers, timeout: formDataState.timeout, workspaceId, }) - }, [descriptor.entityId, formDataState, selectedServer, testConnection, workspaceId]) + }, [copy.unnamedServer, descriptor.entityId, formDataState, selectedServer, testConnection, workspaceId]) const handleRefreshTools = useCallback(async () => { if (!workspaceId || !descriptor.entityId) return try { - await refreshServerApi(descriptor.entityId, workspaceId) + await refreshServerApi(descriptor.entityId, workspaceId, copy.failedToRefreshMcpServer) await refreshServer(workspaceId, descriptor.entityId) await refreshTools(true) await fetchServers(workspaceId) } catch (refreshError) { console.error('Failed to refresh MCP server tools', refreshError) - setSaveError( - refreshError instanceof Error ? refreshError.message : 'Failed to refresh MCP server.' - ) + setSaveError(refreshError instanceof Error ? refreshError.message : copy.failedToRefreshMcpServer) } - }, [descriptor.entityId, fetchServers, refreshServer, refreshTools, workspaceId]) + }, [ + copy.failedToRefreshMcpServer, + descriptor.entityId, + fetchServers, + refreshServer, + refreshTools, + workspaceId, + ]) const handleSave = useCallback(async () => { if (!workspaceId || !descriptor.reviewSessionId) { @@ -347,7 +379,7 @@ function McpEditorSession({ const payload = createMcpSavePayload(formDataState) if (!payload.name) { - setSaveError('Server name is required.') + setSaveError(copy.serverNameRequired) return } @@ -371,7 +403,7 @@ function McpEditorSession({ const responsePayload = await response.json().catch(() => ({})) if (!response.ok) { - throw new Error(responsePayload?.error || 'Failed to save MCP server.') + throw new Error(responsePayload?.error || copy.failedToSaveMcpServer) } await fetchServers(workspaceId) @@ -379,11 +411,11 @@ function McpEditorSession({ onReviewTargetChange?.(responsePayload.reviewTarget as ReviewTargetDescriptor) } } catch (saveError) { - setSaveError( - saveError instanceof Error ? saveError.message : 'Failed to save MCP server.' - ) + setSaveError(saveError instanceof Error ? saveError.message : copy.failedToSaveMcpServer) } }, [ + copy.failedToSaveMcpServer, + copy.serverNameRequired, descriptor.draftSessionId, descriptor.entityId, descriptor.reviewSessionId, @@ -418,7 +450,7 @@ function McpEditorSession({ } if (serverError && descriptor.entityId && workspaceServers.length === 0 && isServersLoading) { - return <WidgetStateMessage message={serverError || 'Failed to load MCP servers.'} /> + return <WidgetStateMessage message={serverError || copy.failedToLoadMcpServers} /> } const displayStatus = descriptor.entityId @@ -432,14 +464,14 @@ function McpEditorSession({ <div className='flex flex-wrap items-center gap-2'> <h3 className='font-medium text-foreground text-sm'> {descriptor.entityId - ? getServerName(selectedServer) - : formDataState.name.trim() || 'Unsaved MCP draft'} + ? getServerName(selectedServer, copy.unnamedServer) + : formDataState.name.trim() || copy.draft} </h3> <span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-medium text-[10px] ${getStatusClassName(displayStatus)}`} > <span className='h-1.5 w-1.5 rounded-full bg-current opacity-70' /> - {getStatusLabel(displayStatus)} + {getStatusLabel(displayStatus, copy)} </span> <span className='rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground'> {formDataState.transport.toUpperCase()} @@ -448,18 +480,30 @@ function McpEditorSession({ {descriptor.entityId && selectedServer ? ( <div className='flex flex-wrap items-center gap-2 text-muted-foreground text-xs'> {selectedServer.updatedAt ? ( - <span>Updated {formatRelativeTime(selectedServer.updatedAt)}</span> + <span> + {formatTemplate(copy.updated, { + time: formatRelativeTime(selectedServer.updatedAt, locale) ?? '', + })} + </span> ) : null} {selectedServer.lastToolsRefresh ? ( - <span>Tools refreshed {formatRelativeTime(selectedServer.lastToolsRefresh)}</span> + <span> + {formatTemplate(copy.toolsRefreshed, { + time: formatRelativeTime(selectedServer.lastToolsRefresh, locale) ?? '', + })} + </span> ) : null} {selectedServer.lastConnected ? ( - <span>Last connected {formatRelativeTime(selectedServer.lastConnected)}</span> + <span> + {formatTemplate(copy.lastConnected, { + time: formatRelativeTime(selectedServer.lastConnected, locale) ?? '', + })} + </span> ) : null} </div> ) : ( <p className='text-muted-foreground text-xs'> - Save this draft to enable connection tests, tool refresh, and canonical reload. + {copy.saveDraftHint} </p> )} </div> @@ -476,9 +520,11 @@ function McpEditorSession({ <div className='space-y-3 rounded-md'> <div className='flex items-center justify-between'> - <p className='text-muted-foreground text-xs uppercase tracking-wide'>Tools</p> + <p className='text-muted-foreground text-xs uppercase tracking-wide'>{copy.tools}</p> <span className='text-muted-foreground text-xs'> - {descriptor.entityId ? `${selectedServerTools.length} total` : 'Save required'} + {descriptor.entityId + ? formatTemplate(copy.toolCount, { count: selectedServerTools.length }) + : copy.saveRequired} </span> </div> @@ -498,19 +544,19 @@ function McpEditorSession({ </div> ) : ( <div className='rounded-md border border-dashed px-3 py-4 text-muted-foreground text-sm'> - No tools discovered yet. + {copy.noToolsDiscovered} </div> ) ) : ( <div className='rounded-md border border-dashed px-3 py-4 text-muted-foreground text-sm'> - Save this server to refresh and inspect discovered MCP tools. + {copy.saveThisServerToRefreshAndInspectDiscoveredMcpTools} </div> )} </div> {selectedServer?.lastError ? ( <div className='rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-sm'> - <p className='font-medium'>Last error</p> + <p className='font-medium'>{copy.lastError}</p> <p className='text-destructive/80 text-xs'>{selectedServer.lastError}</p> </div> ) : null} diff --git a/apps/tradinggoose/widgets/widgets/editor_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/editor_mcp/index.tsx index 9fc051eec..62f1aa3f2 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/index.tsx @@ -1,7 +1,10 @@ 'use client' import { Play, RefreshCw, RotateCcw, Save, Server, X } from 'lucide-react' +import { useLocale } from 'next-intl' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import type { PairColor } from '@/widgets/pair-colors' import type { DashboardWidgetDefinition } from '@/widgets/types' import { emitMcpEditorAction } from '@/widgets/utils/mcp-editor-actions' @@ -33,6 +36,8 @@ const McpEditorSelector = ({ pairColor?: PairColor widgetKey?: string }) => { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.mcpEditor const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) @@ -70,7 +75,7 @@ const McpEditorSelector = ({ workspaceId={workspaceId} value={resolvedServerId} onChange={(nextServerId) => handleServerChange(nextServerId)} - placeholder='Select server' + placeholder={copy.selectServer} triggerClassName='min-w-[240px]' /> ) @@ -89,6 +94,8 @@ const McpEditorHeaderActions = ({ pairColor?: PairColor widgetKey?: string }) => { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.mcpEditor const resolvedPairColor = (pairColor ?? 'gray') as PairColor const pairContext = usePairColorContext(resolvedPairColor) const selectionState = readEntitySelectionState({ @@ -123,40 +130,40 @@ const McpEditorHeaderActions = ({ onAction={() => emitAction('redo')} /> <EntityEditorHeaderButton - tooltip='Refresh tools' - label='Refresh tools' + tooltip={copy.refreshTools} + label={copy.refreshTools} icon={RefreshCw} onClick={() => emitAction('refresh')} disabled={!workspaceId || !hasCanonicalEntity} variant='outline' /> <EntityEditorHeaderButton - tooltip='Test connection' - label='Test connection' + tooltip={copy.testConnection} + label={copy.testConnection} icon={Play} onClick={() => emitAction('test')} disabled={!workspaceId || !hasCanonicalEntity} variant='outline' /> <EntityEditorHeaderButton - tooltip='Reset form' - label='Reset form' + tooltip={copy.resetForm} + label={copy.resetForm} icon={RotateCcw} onClick={() => emitAction('reset')} disabled={!hasSelection} variant='secondary' /> <EntityEditorHeaderButton - tooltip='Save server' - label='Save server' + tooltip={copy.saveServer} + label={copy.saveServer} icon={Save} onClick={() => emitAction('save')} disabled={!workspaceId || !hasSelection} variant='default' /> <EntityEditorHeaderButton - tooltip='Clear selection' - label='Clear selection' + tooltip={copy.clearSelection} + label={copy.clearSelection} icon={X} onClick={() => emitAction('close')} disabled={!hasSelection} diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx index 6691a4317..3d7308e8a 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx @@ -41,7 +41,7 @@ export function SkillEditorSelector({ const setPairContext = useSetPairColorContext() const resolvedSkillId = isLinkedToColorPair - ? (pairContext?.skillId ?? skillId ?? null) + ? (pairContext?.skillId ?? null) : (skillId ?? null) const handleSkillChange = (nextSkillId: string | null) => { @@ -118,7 +118,7 @@ export function SkillEditorExportButton({ const [isDirty, setIsDirty] = useState(true) const resolvedSkillId = isLinkedToColorPair - ? (pairContext?.skillId ?? skillId ?? null) + ? (pairContext?.skillId ?? null) : (skillId ?? null) const skill = useSkillsStore((state) => workspaceId && resolvedSkillId ? state.getSkill(resolvedSkillId, workspaceId) : undefined diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx index b728cf0f6..47dcb3b0f 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx @@ -32,14 +32,14 @@ export function EditorSkillWidgetBody({ const [isDirty, setIsDirty] = useState(false) const paramsSkillId = getSkillIdFromParams(params) - const requestedSkillId = isLinkedToColorPair - ? (pairContext?.skillId ?? paramsSkillId) - : paramsSkillId + const requestedSkillId = isLinkedToColorPair ? (pairContext?.skillId ?? null) : paramsSkillId const normalizedRequestedSkillId = requestedSkillId?.trim() ?? '' const hasRequestedSkill = normalizedRequestedSkillId.length > 0 && skills.some((skill) => skill.id === normalizedRequestedSkillId) - const skillId = hasRequestedSkill ? normalizedRequestedSkillId : (skills[0]?.id ?? null) + const skillId = hasRequestedSkill + ? normalizedRequestedSkillId + : (isLinkedToColorPair ? null : (skills[0]?.id ?? null)) const skill = skillId ? (skills.find((candidate) => candidate.id === skillId) ?? null) : null useEffect(() => { @@ -136,7 +136,17 @@ export function EditorSkillWidgetBody({ } if (!skillId) { - return <WidgetStateMessage message='Select a skill to edit.' /> + return ( + <WidgetStateMessage + message={ + isLinkedToColorPair + ? normalizedRequestedSkillId.length > 0 + ? 'Skill not found.' + : 'This color has no shared skill selected yet.' + : 'Select a skill to edit.' + } + /> + ) } if (!skill) { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts index 49ed789b6..840673fdd 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts @@ -2,34 +2,10 @@ import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('AutoLayoutUtils') -/** - * Default auto layout options (now using native compact spacing) - */ -export const DEFAULT_AUTO_LAYOUT_OPTIONS: AutoLayoutOptions = { - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 550, - vertical: 200, - layer: 550, - }, - alignment: 'center', - padding: { - x: 150, - y: 150, - }, -} - -/** - * Auto layout options interface - */ -export interface AutoLayoutOptions { - strategy?: 'smart' | 'hierarchical' | 'layered' | 'force-directed' - direction?: 'horizontal' | 'vertical' | 'auto' +interface AutoLayoutOptions { spacing?: { horizontal?: number vertical?: number - layer?: number } alignment?: 'start' | 'center' | 'end' padding?: { @@ -72,15 +48,10 @@ function sanitizeEdgesForStateSave(edges: any[]): any[] { }) } -/** - * Apply auto layout to workflow blocks and update the store - */ export async function applyAutoLayoutToWorkflow( workflowId: string, blocks: Record<string, any>, edges: any[], - loops: Record<string, any> = {}, - parallels: Record<string, any> = {}, options: AutoLayoutOptions = {} ): Promise<{ success: boolean @@ -94,25 +65,18 @@ export async function applyAutoLayoutToWorkflow( edgeCount: edges.length, }) - // Call the autolayout API route instead of copilot directly - - // Merge with default options and ensure all required properties are present const layoutOptions = { - strategy: options.strategy || DEFAULT_AUTO_LAYOUT_OPTIONS.strategy!, - direction: options.direction || DEFAULT_AUTO_LAYOUT_OPTIONS.direction!, spacing: { - horizontal: options.spacing?.horizontal || DEFAULT_AUTO_LAYOUT_OPTIONS.spacing!.horizontal!, - vertical: options.spacing?.vertical || DEFAULT_AUTO_LAYOUT_OPTIONS.spacing!.vertical!, - layer: options.spacing?.layer || DEFAULT_AUTO_LAYOUT_OPTIONS.spacing!.layer!, + horizontal: options.spacing?.horizontal ?? 550, + vertical: options.spacing?.vertical ?? 200, }, - alignment: options.alignment || DEFAULT_AUTO_LAYOUT_OPTIONS.alignment!, + alignment: options.alignment ?? 'center', padding: { - x: options.padding?.x || DEFAULT_AUTO_LAYOUT_OPTIONS.padding!.x!, - y: options.padding?.y || DEFAULT_AUTO_LAYOUT_OPTIONS.padding!.y!, + x: options.padding?.x ?? 150, + y: options.padding?.y ?? 150, }, } - // Call the autolayout API route, sending blocks with live measurements const response = await fetch(`/api/workflows/${workflowId}/autolayout`, { method: 'POST', headers: { @@ -122,8 +86,6 @@ export async function applyAutoLayoutToWorkflow( ...layoutOptions, blocks, edges, - loops, - parallels, }), }) @@ -161,7 +123,6 @@ export async function applyAutoLayoutToWorkflow( : 0, }) - // Return the layouted blocks from the API response return { success: true, layoutedBlocks: result.data?.layoutedBlocks || blocks, @@ -177,21 +138,16 @@ export async function applyAutoLayoutToWorkflow( } } -/** - * Apply auto layout and update the workflow store immediately - */ interface ApplyAutoLayoutAndUpdateStoreParams { workflowId: string channelId?: string options?: AutoLayoutOptions - undoUserId?: string } export async function applyAutoLayoutAndUpdateStore({ workflowId, channelId, options = {}, - undoUserId, }: ApplyAutoLayoutAndUpdateStoreParams): Promise<{ success: boolean error?: string @@ -199,7 +155,6 @@ export async function applyAutoLayoutAndUpdateStore({ let resolvedWorkflowId: string | undefined = workflowId try { - // Import Yjs session registry for imperative access const { getRegisteredWorkflowSession } = await import('@/lib/yjs/workflow-session-registry') const { getWorkflowSnapshot, getWorkflowMap } = await import('@/lib/yjs/workflow-session') const { YJS_ORIGINS } = await import('@/lib/yjs/transaction-origins') @@ -222,23 +177,22 @@ export async function applyAutoLayoutAndUpdateStore({ }) } - // Read workflow state from Yjs doc const session = getRegisteredWorkflowSession(resolvedWorkflowId) if (!session?.doc) { - logger.error('Auto layout aborted: no Yjs session for workflow', { workflowId: resolvedWorkflowId }) + logger.error('Auto layout aborted: no Yjs session for workflow', { + workflowId: resolvedWorkflowId, + }) return { success: false, error: 'No active workflow session' } } const snapshot = getWorkflowSnapshot(session.doc) - const { blocks, edges, loops = {}, parallels = {} } = snapshot + const { blocks, edges } = snapshot const hasLockedBlocks = Object.values(blocks).some((block) => Boolean(block.locked)) logger.info('Auto layout store data:', { workflowId: resolvedWorkflowId, blockCount: Object.keys(blocks).length, edgeCount: edges.length, - loopCount: Object.keys(loops).length, - parallelCount: Object.keys(parallels).length, }) if (Object.keys(blocks).length === 0) { @@ -256,21 +210,12 @@ export async function applyAutoLayoutAndUpdateStore({ } } - // Apply auto layout - const result = await applyAutoLayoutToWorkflow( - resolvedWorkflowId, - blocks, - edges, - loops, - parallels, - options - ) + const result = await applyAutoLayoutToWorkflow(resolvedWorkflowId, blocks, edges, options) if (!result.success || !result.layoutedBlocks) { return { success: false, error: result.error } } - // Update Yjs doc directly with new block positions const doc = session.doc doc.transact(() => { const wMap = getWorkflowMap(doc) @@ -283,12 +228,9 @@ export async function applyAutoLayoutAndUpdateStore({ channelId, }) - // Persist the changes to the database optimistically try { const updatedSnapshot = getWorkflowSnapshot(doc) - // Clean up the workflow state for API validation. - // Undefined keys are omitted during JSON serialization. const stateToSave = { ...updatedSnapshot, deploymentStatuses: undefined, @@ -298,15 +240,14 @@ export async function applyAutoLayoutAndUpdateStore({ const cleanedWorkflowState = { ...stateToSave, - // Convert null dates to undefined (since they're optional) - deployedAt: (stateToSave as any).deployedAt ? new Date((stateToSave as any).deployedAt) : undefined, - // Ensure other optional fields are properly handled + deployedAt: (stateToSave as any).deployedAt + ? new Date((stateToSave as any).deployedAt) + : undefined, loops: stateToSave.loops || {}, parallels: stateToSave.parallels || {}, edges: sanitizeEdgesForStateSave(stateToSave.edges || []), } - // Save the updated workflow state to the database const response = await fetch(`/api/workflows/${resolvedWorkflowId}/state`, { method: 'PUT', headers: { @@ -348,15 +289,16 @@ export async function applyAutoLayoutAndUpdateStore({ error: message, }) - // Revert the Yjs doc changes since database save failed doc.transact(() => { const wMap = getWorkflowMap(doc) - wMap.set('blocks', blocks) // Revert to original blocks + wMap.set('blocks', blocks) }, YJS_ORIGINS.SYSTEM) return { success: false, - error: `Failed to save positions to database: ${saveError instanceof Error ? saveError.message : 'Unknown error'}`, + error: `Failed to save positions to database: ${ + saveError instanceof Error ? saveError.message : 'Unknown error' + }`, } } } catch (error) { @@ -372,18 +314,3 @@ export async function applyAutoLayoutAndUpdateStore({ } } } - -/** - * Apply auto layout to a specific set of blocks (used by copilot preview) - */ -export async function applyAutoLayoutToBlocks( - blocks: Record<string, any>, - edges: any[], - options: AutoLayoutOptions = {} -): Promise<{ - success: boolean - layoutedBlocks?: Record<string, any> - error?: string -}> { - return applyAutoLayoutToWorkflow('preview', blocks, edges, {}, {}, options) -} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/api-key-selector/api-key-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/api-key-selector/api-key-selector.tsx index f7177f8e8..a214ac67b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/api-key-selector/api-key-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/api-key-selector/api-key-selector.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Check, Copy, Info, Loader2, Plus } from 'lucide-react' +import { useLocale } from 'next-intl' import { AlertDialog, AlertDialogAction, @@ -26,6 +27,8 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { createLogger } from '@/lib/logs/console/logger' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' @@ -67,10 +70,12 @@ export function ApiKeySelector({ apiKeys = [], onApiKeyCreated, showLabel = true, - label = 'API Key', + label, isDeployed = false, deployedApiKeyDisplay, }: ApiKeySelectorProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.apiKey const workspaceId = useWorkspaceId() const userPermissions = useUserPermissionsContext() const canCreateWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin @@ -86,6 +91,7 @@ export function ApiKeySelector({ const [keysLoaded, setKeysLoaded] = useState(false) const [createError, setCreateError] = useState<string | null>(null) const [justCreatedKeyId, setJustCreatedKeyId] = useState<string | null>(null) + const resolvedLabel = label ?? copy.apiKey useEffect(() => { fetchApiKeys() @@ -115,7 +121,7 @@ export function ApiKeySelector({ const handleCreateKey = async () => { if (!newKeyName.trim()) { - setCreateError('Please enter a name for the API key') + setCreateError(copy.enterName) return } @@ -136,7 +142,7 @@ export function ApiKeySelector({ if (!response.ok) { const error = await response.json() - throw new Error(error.error || 'Failed to create API key') + throw new Error(error.error || copy.failedToCreate) } const data = await response.json() @@ -150,7 +156,7 @@ export function ApiKeySelector({ await fetchApiKeys() onApiKeyCreated?.() } catch (error: any) { - setCreateError(error.message || 'Failed to create API key') + setCreateError(error.message || copy.failedToCreate) } finally { setIsSubmittingCreate(false) } @@ -169,14 +175,14 @@ export function ApiKeySelector({ <div className='space-y-1.5'> {showLabel && ( <div className='flex items-center gap-1.5'> - <Label className='font-medium text-sm'>{label}</Label> + <Label className='font-medium text-sm'>{resolvedLabel}</Label> <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <Info className='h-3.5 w-3.5 text-muted-foreground' /> </TooltipTrigger> <TooltipContent> - <p>Owner is billed for usage</p> + <p>{copy.ownerIsBilledForUsage}</p> </TooltipContent> </Tooltip> </TooltipProvider> @@ -219,14 +225,14 @@ export function ApiKeySelector({ {showLabel && ( <div className='flex items-center justify-between'> <div className='flex items-center gap-1.5'> - <Label className='font-medium text-sm'>{label}</Label> + <Label className='font-medium text-sm'>{resolvedLabel}</Label> <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <Info className='h-3.5 w-3.5 text-muted-foreground' /> </TooltipTrigger> <TooltipContent> - <p>Key Owner is Billed</p> + <p>{copy.keyOwnerIsBilled}</p> </TooltipContent> </Tooltip> </TooltipProvider> @@ -243,7 +249,7 @@ export function ApiKeySelector({ }} > <Plus className='h-3.5 w-3.5' /> - <span>Create new</span> + <span>{copy.createNew}</span> </Button> )} </div> @@ -253,17 +259,17 @@ export function ApiKeySelector({ {!keysLoaded ? ( <div className='flex items-center space-x-2'> <Loader2 className='h-3.5 w-3.5 animate-spin' /> - <span>Loading API keys...</span> + <span>{copy.loadingApiKeys}</span> </div> ) : ( - <SelectValue placeholder='Select an API key' className='text-sm' /> + <SelectValue placeholder={copy.selectAnApiKey} className='text-sm' /> )} </SelectTrigger> <SelectContent align='start' className='w-[var(--radix-select-trigger-width)] py-1'> {apiKeysData && apiKeysData.workspace.length > 0 && ( <SelectGroup> <SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'> - Workspace + {copy.workspaceLabel} </SelectLabel> {apiKeysData.workspace.map((apiKey) => ( <SelectItem @@ -288,7 +294,7 @@ export function ApiKeySelector({ (!apiKeysData && apiKeys.length > 0)) && ( <SelectGroup> <SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'> - Personal + {copy.personalLabel} </SelectLabel> {(apiKeysData ? apiKeysData.personal : apiKeys).map((apiKey) => ( <SelectItem @@ -310,13 +316,17 @@ export function ApiKeySelector({ )} {!apiKeysData && apiKeys.length === 0 && ( - <div className='px-3 py-2 text-muted-foreground text-sm'>No API keys available</div> + <div className='px-3 py-2 text-muted-foreground text-sm'> + {copy.noApiKeysAvailable} + </div> )} {apiKeysData && apiKeysData.workspace.length === 0 && apiKeysData.personal.length === 0 && ( - <div className='px-3 py-2 text-muted-foreground text-sm'>No API keys available</div> + <div className='px-3 py-2 text-muted-foreground text-sm'> + {copy.noApiKeysAvailable} + </div> )} </SelectContent> </Select> @@ -326,18 +336,18 @@ export function ApiKeySelector({ <AlertDialog open={isCreatingKey} onOpenChange={setIsCreatingKey}> <AlertDialogContent className='rounded-md sm:max-w-md'> <AlertDialogHeader> - <AlertDialogTitle>Create new API key</AlertDialogTitle> + <AlertDialogTitle>{copy.createNewApiKey}</AlertDialogTitle> <AlertDialogDescription> {keyType === 'workspace' - ? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again." - : "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."} + ? copy.workspaceAccess + : copy.personalAccess} </AlertDialogDescription> </AlertDialogHeader> <div className='space-y-4 py-2'> {canCreateWorkspaceKeys && ( <div className='space-y-2'> - <p className='font-[360] text-sm'>API Key Type</p> + <p className='font-[360] text-sm'>{copy.apiKeyType}</p> <div className='flex gap-2'> <Button type='button' @@ -349,7 +359,7 @@ export function ApiKeySelector({ }} className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-card dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-card/80' > - Personal + {copy.personal} </Button> <Button type='button' @@ -361,17 +371,17 @@ export function ApiKeySelector({ }} className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-card dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-card/80' > - Workspace + {copy.workspace} </Button> </div> </div> )} <div className='space-y-2'> - <Label htmlFor='new-key-name'>API Key Name</Label> + <Label htmlFor='new-key-name'>{copy.apiKeyName}</Label> <Input id='new-key-name' - placeholder='My API Key' + placeholder={copy.myApiKey} value={newKeyName} onChange={(e) => { setNewKeyName(e.target.value) @@ -391,7 +401,7 @@ export function ApiKeySelector({ setCreateError(null) }} > - Cancel + {copy.cancel} </AlertDialogCancel> <AlertDialogAction disabled={isSubmittingCreate || !newKeyName.trim()} @@ -403,10 +413,10 @@ export function ApiKeySelector({ {isSubmittingCreate ? ( <> <Loader2 className='mr-1.5 h-3 w-3 animate-spin' /> - Creating... + {copy.creating} </> ) : ( - 'Create' + copy.create )} </AlertDialogAction> </AlertDialogFooter> @@ -430,10 +440,10 @@ export function ApiKeySelector({ > <AlertDialogContent className='rounded-md sm:max-w-md'> <AlertDialogHeader> - <AlertDialogTitle>Your API key has been created</AlertDialogTitle> + <AlertDialogTitle>{copy.apiKeyHasBeenCreated}</AlertDialogTitle> <AlertDialogDescription> - This is the only time you will see your API key.{' '} - <span className='font-semibold'>Copy it now and store it securely.</span> + {copy.onlyTimeYouWillSeeYourApiKey}{' '} + <span className='font-semibold'>{copy.copyItNowAndStoreItSecurely}</span> </AlertDialogDescription> </AlertDialogHeader> @@ -451,7 +461,7 @@ export function ApiKeySelector({ onClick={handleCopyKey} > {copySuccess ? <Check className='h-3.5 w-3.5' /> : <Copy className='h-3.5 w-3.5' />} - <span className='sr-only'>Copy to clipboard</span> + <span className='sr-only'>{copy.copyToClipboard}</span> </Button> </div> )} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx index 9cbd96300..3405aac33 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx @@ -1,22 +1,22 @@ 'use client' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { zodResolver } from '@hookform/resolvers/zod' +import { useLocale } from 'next-intl' import { useForm } from 'react-hook-form' import { z } from 'zod' import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { type ApiKey, ApiKeySelector, } from '@/widgets/widgets/editor_workflow/components/control-bar/components/api-key-selector/api-key-selector' -// Form schema for API key selection or creation -const deployFormSchema = z.object({ - apiKey: z.string().min(1, 'Please select an API key'), - newKeyName: z.string().optional(), -}) - -type DeployFormValues = z.infer<typeof deployFormSchema> +type DeployFormValues = { + apiKey: string + newKeyName?: string +} interface DeployFormProps { apiKeys: ApiKey[] @@ -39,6 +39,16 @@ export function DeployForm({ isDeployed = false, deployedApiKeyDisplay, }: DeployFormProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.apiKey + const deployFormSchema = useMemo( + () => + z.object({ + apiKey: z.string().min(1, copy.selectAnApiKey), + newKeyName: z.string().optional(), + }), + [copy.selectAnApiKey] + ) const form = useForm<DeployFormValues>({ resolver: zodResolver(deployFormSchema), defaultValues: { @@ -77,7 +87,7 @@ export function DeployForm({ apiKeys={apiKeys} onApiKeyCreated={onApiKeyCreated} showLabel={true} - label='Select API Key' + label={copy.selectApiKey} isDeployed={isDeployed} deployedApiKeyDisplay={deployedApiKeyDisplay} /> diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx index 0f20cc180..8cc02a2e9 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx @@ -2,8 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Loader2, Rocket } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import { DeployModal } from '@/widgets/widgets/editor_workflow/components/control-bar/components' import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions' @@ -34,6 +37,8 @@ export function DeploymentControls({ userPermissions, variant = 'workspace', }: DeploymentControlsProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.deployment const deploymentStatus = useWorkflowRegistry((state) => state.getWorkflowDeploymentStatus(activeWorkflowId) ) @@ -72,18 +77,18 @@ export function DeploymentControls({ const getTooltipText = () => { if (!canDeploy) { - return 'Admin permissions required to deploy workflows' + return copy.adminPermissionsRequiredToDeployWorkflows } if (isDeploying) { - return 'Deploying...' + return copy.deploying } if (isDeployed && workflowNeedsRedeployment) { - return 'Workflow changes detected' + return copy.workflowChangesDetected } if (isDeployed) { - return 'Deployment Settings' + return copy.deploymentSettings } - return 'Deploy Workflow' + return copy.deployWorkflow } const buttonBaseClass = @@ -116,7 +121,7 @@ export function DeploymentControls({ ) : ( <Rocket className='h-5 w-5' /> )} - <span className='sr-only'>Deploy API</span> + <span className='sr-only'>{copy.deployApi}</span> </Button> {isDeployed && workflowNeedsRedeployment && ( @@ -125,7 +130,7 @@ export function DeploymentControls({ <div className='absolute inset-0 h-[6px] w-[6px] animate-ping rounded-full bg-yellow-500/50' /> <div className='zoom-in fade-in relative h-[6px] w-[6px] animate-in rounded-full bg-yellow-500/80 duration-300' /> </div> - <span className='sr-only'>Needs Redeployment</span> + <span className='sr-only'>{copy.needsRedeployment}</span> </div> )} </div> 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 ec964b7e9..f01cb420a 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 @@ -354,7 +354,6 @@ export function ControlBar({ const result = await applyAutoLayoutAndUpdateStore({ workflowId: activeWorkflowId!, channelId, - undoUserId: session?.user?.id, }) if (result.success) { 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 573e778df..c2ed57506 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 @@ -2,8 +2,11 @@ import { useEffect, useMemo, useState } from 'react' 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 { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { createLogger } from '@/lib/logs/console/logger' import { getIconTileStyle } from '@/lib/ui/icon-colors' import { cn } from '@/lib/utils' @@ -27,6 +30,8 @@ interface TriggerListProps { } export function TriggerList({ onSelect, className }: TriggerListProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.triggerList const [searchQuery, setSearchQuery] = useState('') const [showList, setShowList] = useState(false) const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>( @@ -205,7 +210,7 @@ export function TriggerList({ onSelect, className }: TriggerListProps) { )} > <Plus className='h-4 w-4' /> - Click to Add Trigger + {copy.openTriggerList} </button> )} @@ -225,7 +230,7 @@ export function TriggerList({ onSelect, className }: TriggerListProps) { <div className='flex items-center border-b px-4 py-1'> <Search className='h-4 w-4 font-sans text-muted-foreground text-xl' /> <Input - placeholder='Search triggers' + placeholder={copy.searchPlaceholder} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className='!font-[350] border-0 bg-transparent font-sans text-muted-foreground leading-10 tracking-normal placeholder:text-muted-foreground focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0' @@ -240,7 +245,7 @@ export function TriggerList({ onSelect, className }: TriggerListProps) { tabIndex={-1} > <X className='h-4 w-4' /> - <span className='sr-only'>Close</span> + <span className='sr-only'>{copy.close}</span> </button> {/* Trigger List */} @@ -253,7 +258,7 @@ export function TriggerList({ onSelect, className }: TriggerListProps) { {coreOptions.length > 0 && ( <div> <h3 className='mb-2 ml-4 font-normal font-sans text-[13px] text-muted-foreground leading-none tracking-normal'> - Core Triggers + {copy.coreTriggers} </h3> <div className='px-4 pb-1'> <div className='grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-2'> @@ -269,7 +274,7 @@ export function TriggerList({ onSelect, className }: TriggerListProps) { {integrationOptions.length > 0 && ( <div> <h3 className='mb-2 ml-4 font-normal font-sans text-[13px] text-muted-foreground leading-none tracking-normal'> - Integration Triggers + {copy.integrationTriggers} </h3> <div className='max-h-[300px] overflow-y-auto px-4 pb-1' @@ -286,7 +291,9 @@ export function TriggerList({ onSelect, className }: TriggerListProps) { {filteredOptions.length === 0 && ( <div className='ml-6 py-12 text-center'> - <p className='text-muted-foreground'>No results found for "{searchQuery}"</p> + <p className='text-muted-foreground'> + {copy.noResults.replace('{{query}}', searchQuery)} + </p> </div> )} </div> diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.test.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.test.tsx new file mode 100644 index 000000000..d28451110 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.test.tsx @@ -0,0 +1,72 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OAuthRequiredModal } from './oauth-required-modal' + +const mockStartOAuthConnectFlow = vi.fn() + +vi.mock('@/lib/oauth/connect', () => ({ + startOAuthConnectFlow: (...args: unknown[]) => mockStartOAuthConnectFlow(...args), +})) + +describe('OAuthRequiredModal', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + document.body.replaceChildren() + }) + + it('requires an explicit Alpaca live or paper connection when scopes match both services', async () => { + const onClose = vi.fn() + + act(() => { + root.render( + <OAuthRequiredModal + isOpen + onClose={onClose} + provider='alpaca' + toolName='Trading' + requiredScopes={['trading', 'data']} + /> + ) + }) + + expect(document.body.textContent).toContain('Connect Alpaca Live') + expect(document.body.textContent).toContain('Connect Alpaca Paper') + + const paperButton = Array.from(document.body.querySelectorAll('button')).find((button) => + button.textContent?.includes('Connect Alpaca Paper') + ) + expect(paperButton).toBeTruthy() + + await act(async () => { + paperButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(mockStartOAuthConnectFlow).toHaveBeenCalledWith({ + providerId: 'alpaca-paper', + callbackURL: window.location.href, + }) + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index a14aaa97f..323e6d0d9 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -13,7 +13,7 @@ import { import { createLogger } from '@/lib/logs/console/logger' import { getProviderIdFromServiceId, - getServiceIdFromScopes, + getServiceIdsFromScopes, OAUTH_PROVIDERS, type OAuthProvider, parseProvider, @@ -29,6 +29,7 @@ export interface OAuthRequiredModalProps { toolName: string requiredScopes?: string[] serviceId?: string + serviceIds?: string[] } // Map of OAuth scopes to user-friendly descriptions @@ -135,11 +136,39 @@ export function OAuthRequiredModal({ toolName, requiredScopes = [], serviceId, + serviceIds, }: OAuthRequiredModalProps) { - // Get provider configuration and service - const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes) const { baseProvider } = parseProvider(provider) const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] + const resolveExplicitServiceId = (candidate?: string) => { + const normalized = candidate?.trim() + if (!normalized) return undefined + if (baseProviderConfig?.services[normalized]) return normalized + const service = Object.values(baseProviderConfig?.services ?? {}).find( + (item) => item.providerId === normalized + ) + if (service) return service.id + return normalized === baseProviderConfig?.id ? undefined : normalized + } + const explicitServiceId = resolveExplicitServiceId(serviceId) + const explicitServiceIds = Array.from( + new Set( + (serviceIds ?? []).map(resolveExplicitServiceId).filter((id): id is string => Boolean(id)) + ) + ) + const inferredServiceIds = (() => { + const providerServiceIds = Object.keys(baseProviderConfig?.services ?? {}) + if (requiredScopes.length === 0 && providerServiceIds.length > 1) { + return providerServiceIds + } + return getServiceIdsFromScopes(provider, requiredScopes) + })() + const effectiveServiceIds = explicitServiceId + ? [explicitServiceId] + : explicitServiceIds.length + ? explicitServiceIds + : inferredServiceIds + const effectiveServiceId = effectiveServiceIds[0] ?? provider // Default to base provider name and icon let providerName = baseProviderConfig?.name || provider @@ -148,7 +177,10 @@ export function OAuthRequiredModal({ // Try to find the specific service if (baseProviderConfig) { for (const service of Object.values(baseProviderConfig.services)) { - if (service.id === effectiveServiceId || service.providerId === provider) { + if ( + (effectiveServiceIds.length === 1 && service.id === effectiveServiceId) || + (effectiveServiceIds.length === 1 && service.providerId === provider) + ) { providerName = service.name ProviderIcon = service.icon break @@ -161,10 +193,15 @@ export function OAuthRequiredModal({ (scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile') ) - const handleConnectDirectly = async () => { + const getServiceName = (connectServiceId: string) => { + if (!baseProviderConfig) return connectServiceId + return baseProviderConfig.services[connectServiceId]?.name ?? connectServiceId + } + + const handleConnectDirectly = async (connectServiceId: string) => { try { // Determine the appropriate serviceId and providerId - const providerId = getProviderIdFromServiceId(effectiveServiceId) + const providerId = getProviderIdFromServiceId(connectServiceId) // Close the modal onClose() @@ -228,9 +265,26 @@ export function OAuthRequiredModal({ <Button variant='outline' onClick={onClose} className='sm:order-1'> Cancel </Button> - <Button type='button' onClick={handleConnectDirectly} className='sm:order-3'> - Connect Now - </Button> + {effectiveServiceIds.length > 1 ? ( + effectiveServiceIds.map((connectServiceId) => ( + <Button + key={connectServiceId} + type='button' + onClick={() => handleConnectDirectly(connectServiceId)} + className='sm:order-3' + > + Connect {getServiceName(connectServiceId)} + </Button> + )) + ) : ( + <Button + type='button' + onClick={() => handleConnectDirectly(effectiveServiceId)} + className='sm:order-3' + > + Connect Now + </Button> + )} </DialogFooter> </DialogContent> </Dialog> 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 0be505436..248d02f26 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 @@ -16,15 +16,18 @@ import { createLogger } from '@/lib/logs/console/logger' import { type Credential, getProviderIdFromServiceId, + getServiceByProviderAndId, getServiceIdFromScopes, + getServiceIdsFromScopes, OAUTH_PROVIDERS, type OAuthProvider, + type OAuthService, parseProvider, } from '@/lib/oauth' +import type { SubBlockConfig } from '@/blocks/types' import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { useWorkflowId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' -import type { SubBlockConfig } from '@/blocks/types' const logger = createLogger('CredentialSelector') @@ -55,64 +58,79 @@ export function CredentialSelector({ const requiredScopes = subBlock.requiredScopes || [] const label = subBlock.placeholder || 'Select credential' const serviceId = subBlock.serviceId + const serviceIds = subBlock.serviceIds // Initialize selectedId with the current store value useEffect(() => { setSelectedId(storeValue || '') }, [storeValue]) - // Derive service and provider IDs using useMemo - const effectiveServiceId = useMemo(() => { - return serviceId || getServiceIdFromScopes(provider, requiredScopes) - }, [provider, requiredScopes, serviceId]) + const effectiveServiceIds = useMemo(() => { + const configuredServiceIds = Array.isArray(serviceIds) + ? serviceIds.map((id) => id.trim()).filter(Boolean) + : [] + if (configuredServiceIds.length > 0) return Array.from(new Set(configuredServiceIds)) + if (serviceId?.trim()) return [serviceId.trim()] + return getServiceIdsFromScopes(provider, requiredScopes) + }, [provider, requiredScopes, serviceId, serviceIds]) + + const primaryServiceId = + effectiveServiceIds[0] ?? getServiceIdFromScopes(provider, requiredScopes) + + const effectiveProviderIds = useMemo( + () => Array.from(new Set(effectiveServiceIds.map((id) => getProviderIdFromServiceId(id)))), + [effectiveServiceIds] + ) - const effectiveProviderId = useMemo(() => { - return getProviderIdFromServiceId(effectiveServiceId) - }, [effectiveServiceId]) + const [oauthModalServiceId, setOAuthModalServiceId] = useState<OAuthService | null>(null) // Fetch available credentials for this provider const fetchCredentials = useCallback(async () => { setIsLoading(true) try { - const response = await fetch(`/api/auth/oauth/credentials?provider=${effectiveProviderId}`) - if (response.ok) { - const data = await response.json() - const creds = data.credentials as Credential[] - let foreignMetaFound = false - - // If persisted selection is not among viewer's credentials, attempt to fetch its metadata - if ( - selectedId && - !(creds || []).some((cred: Credential) => cred.id === selectedId) && - activeWorkflowId - ) { - try { - const metaResp = await fetch( - `/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}` - ) - if (metaResp.ok) { - const meta = await metaResp.json() - if (meta.credentials?.length) { - // Mark as foreign, but do NOT merge into list to avoid leaking owner email - foreignMetaFound = true - } + const responses = await Promise.all( + effectiveProviderIds.map(async (providerId) => { + const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`) + if (!response.ok) return [] as Credential[] + const data = await response.json() + return (data.credentials ?? []) as Credential[] + }) + ) + const creds = responses.flat() + let foreignMetaFound = false + + // If persisted selection is not among viewer's credentials, attempt to fetch its metadata + if ( + selectedId && + !(creds || []).some((cred: Credential) => cred.id === selectedId) && + activeWorkflowId + ) { + try { + const metaResp = await fetch( + `/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}` + ) + if (metaResp.ok) { + const meta = await metaResp.json() + if (meta.credentials?.length) { + // Mark as foreign, but do NOT merge into list to avoid leaking owner email + foreignMetaFound = true } - } catch { - // ignore meta errors } + } catch { + // ignore meta errors } + } - setHasForeignMeta(foreignMetaFound) - setCredentials(creds) + setHasForeignMeta(foreignMetaFound) + setCredentials(creds) - // Do not auto-select or reset. We only show what's persisted. - } + // Do not auto-select or reset. We only show what's persisted. } catch (error) { logger.error('Error fetching credentials:', { error }) } finally { setIsLoading(false) } - }, [effectiveProviderId, selectedId, activeWorkflowId]) + }, [effectiveProviderIds, selectedId, activeWorkflowId]) // Fetch credentials on initial mount and whenever the subblock value changes externally useEffect(() => { @@ -122,30 +140,30 @@ export function CredentialSelector({ // When the selectedId changes (e.g., collaborator saved a credential), determine if it's foreign useEffect(() => { let aborted = false - ; (async () => { - try { - if (!selectedId) { - setHasForeignMeta(false) - return - } - // If the selected credential exists in viewer's list, it's not foreign - if ((credentials || []).some((cred) => cred.id === selectedId)) { - setHasForeignMeta(false) - return - } - if (!activeWorkflowId) return - const metaResp = await fetch( - `/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}` - ) - if (aborted) return - if (metaResp.ok) { - const meta = await metaResp.json() - setHasForeignMeta(!!meta.credentials?.length) - } - } catch { - // ignore + ;(async () => { + try { + if (!selectedId) { + setHasForeignMeta(false) + return } - })() + // If the selected credential exists in viewer's list, it's not foreign + if ((credentials || []).some((cred) => cred.id === selectedId)) { + setHasForeignMeta(false) + return + } + if (!activeWorkflowId) return + const metaResp = await fetch( + `/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}` + ) + if (aborted) return + if (metaResp.ok) { + const meta = await metaResp.json() + setHasForeignMeta(!!meta.credentials?.length) + } + } catch { + // ignore + } + })() return () => { aborted = true } @@ -209,8 +227,9 @@ export function CredentialSelector({ } // Handle adding a new credential - const handleAddCredential = () => { + const handleAddCredential = (connectServiceId: OAuthService) => { // Show the OAuth modal + setOAuthModalServiceId(connectServiceId) setShowOAuthModal(true) setOpen(false) } @@ -243,6 +262,16 @@ export function CredentialSelector({ .join(' ') } + const getServiceName = (connectServiceId: string) => { + try { + return getServiceByProviderAndId(provider, connectServiceId).name + } catch { + return getProviderName(connectServiceId) + } + } + + const showServiceNames = effectiveServiceIds.length > 1 + return ( <> <Popover open={open} onOpenChange={handleOpenChange}> @@ -295,9 +324,14 @@ export function CredentialSelector({ value={cred.id} onSelect={() => handleSelect(cred.id)} > - <div className='flex items-center gap-1'> + <div className='flex min-w-0 items-center gap-1'> {getProviderIcon(cred.provider)} - <span className='font-normal'>{cred.name}</span> + <span className='min-w-0 truncate font-normal'>{cred.name}</span> + {showServiceNames ? ( + <span className='shrink-0 text-muted-foreground text-xs'> + {getServiceName(cred.provider)} + </span> + ) : null} </div> {cred.id === selectedId && <Check className='ml-auto h-4 w-4' />} </CommandItem> @@ -306,12 +340,32 @@ export function CredentialSelector({ )} {credentials.length === 0 && ( <CommandGroup> - <CommandItem onSelect={handleAddCredential}> - <div className='flex items-center gap-1 text-foreground'> - {getProviderIcon(provider)} - <span>Connect {getProviderName(provider)} account</span> - </div> - </CommandItem> + {effectiveServiceIds.map((connectServiceId) => ( + <CommandItem + key={connectServiceId} + onSelect={() => handleAddCredential(connectServiceId)} + > + <div className='flex items-center gap-1 text-foreground'> + {getProviderIcon(connectServiceId)} + <span>Connect {getServiceName(connectServiceId)} account</span> + </div> + </CommandItem> + ))} + </CommandGroup> + )} + {credentials.length > 0 && showServiceNames && ( + <CommandGroup> + {effectiveServiceIds.map((connectServiceId) => ( + <CommandItem + key={`connect-${connectServiceId}`} + onSelect={() => handleAddCredential(connectServiceId)} + > + <div className='flex items-center gap-1 text-foreground'> + {getProviderIcon(connectServiceId)} + <span>Connect {getServiceName(connectServiceId)} account</span> + </div> + </CommandItem> + ))} </CommandGroup> )} </CommandList> @@ -326,7 +380,10 @@ export function CredentialSelector({ provider={provider} toolName={getProviderName(provider)} requiredScopes={requiredScopes} - serviceId={effectiveServiceId} + serviceId={ + oauthModalServiceId ?? (effectiveServiceIds.length === 1 ? primaryServiceId : undefined) + } + serviceIds={effectiveServiceIds} /> )} </> 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 85692ee0c..19c3559a6 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,12 +1,19 @@ 'use client' import { useMemo, useState } from 'react' +import { useLocale } from 'next-intl' import { ToolCase, XIcon } from 'lucide-react' import { cn } from '@/lib/utils' import { useSkills } from '@/hooks/queries/skills' 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' +import type { LocaleCode } from '@/i18n/utils' +import { + formatWorkflowTemplate, + getWorkflowLabelCopy, + translateWorkflowLabel, +} from '@/widgets/workflow-labels' const SKILL_COLOR = '#10b981' // emerald-500 @@ -22,6 +29,7 @@ interface SkillInputProps { } export function SkillInput({ blockId, subBlockId, disabled = false }: SkillInputProps) { + const locale = useLocale() as LocaleCode const workspaceId = useWorkspaceId() const [storedValue, setStoredValue] = useSubBlockValue<StoredSkill[]>(blockId, subBlockId) const { data: workspaceSkills = [] } = useSkills(workspaceId) @@ -44,9 +52,9 @@ export function SkillInput({ blockId, subBlockId, disabled = false }: SkillInput label: skill.name, id: skill.id, icon: ToolCase, - group: 'Skills', + group: translateWorkflowLabel(locale, 'Skills'), })) - }, [workspaceSkills, selectedSkillIds]) + }, [locale, workspaceSkills, selectedSkillIds]) const handleSkillSelection = (skillId: string) => { if (disabled || !skillId) return @@ -70,14 +78,14 @@ export function SkillInput({ blockId, subBlockId, disabled = false }: SkillInput blockId={blockId} subBlockId={`${subBlockId}-skill-selector`} options={dropdownOptions} - placeholder='Add Skill' + placeholder={translateWorkflowLabel(locale, 'Add Skill')} useStore={false} valueOverride={selectorValue} onChange={handleSkillSelection} disabled={disabled || !workspaceId} className='w-full' enableSearch - searchPlaceholder='Search skills...' + searchPlaceholder={translateWorkflowLabel(locale, 'Search skills...')} /> ) : ( <div className='flex min-h-[2.5rem] w-full flex-wrap gap-2 rounded-md border border-input bg-transparent p-2 text-sm ring-offset-background'> @@ -110,7 +118,9 @@ 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={`Remove ${resolvedName}`} + aria-label={formatWorkflowTemplate(getWorkflowLabelCopy(locale).removeSkill, { + name: resolvedName, + })} > <XIcon className='h-3.5 w-3.5' /> </button> @@ -124,14 +134,14 @@ export function SkillInput({ blockId, subBlockId, disabled = false }: SkillInput blockId={blockId} subBlockId={`${subBlockId}-skill-selector-inline`} options={dropdownOptions} - placeholder='Add Skill' + placeholder={translateWorkflowLabel(locale, 'Add Skill')} useStore={false} valueOverride={selectorValue} onChange={handleSkillSelection} disabled={disabled || !workspaceId} className='w-full' enableSearch - searchPlaceholder='Search skills...' + searchPlaceholder={translateWorkflowLabel(locale, 'Search skills...')} /> </div> )} 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 fda6ab880..bb1d13b05 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 @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { format } from 'date-fns' import { Server, WrenchIcon, XIcon } from 'lucide-react' import { useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' import { DateTimePicker } from '@/components/ui/datetime-picker' import { SimpleTimePicker } from '@/components/ui/simple-time-picker' import { Slider } from '@/components/ui/slider' @@ -28,6 +29,7 @@ import { useWorkflowMutations } from '@/lib/yjs/use-workflow-doc' import { getAllBlocks } from '@/blocks' import { useCustomTools } from '@/hooks/queries/custom-tools' import { useMcpTools } from '@/hooks/use-mcp-tools' +import { localizeHref, type LocaleCode } from '@/i18n/utils' import { getProviderFromModel, supportsToolUsageControl } from '@/providers/ai/utils' import type { CustomToolDefinition } from '@/stores/custom-tools/types' import { @@ -492,6 +494,7 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false const workspaceId = useWorkspaceId() const workflowId = useWorkflowId() const router = useRouter() + const locale = useLocale() as LocaleCode const { setSubBlockValue: yjsSetSubBlockValue } = useWorkflowMutations() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) const [modelValue] = useSubBlockValue<string | null>(blockId, 'model') @@ -725,7 +728,7 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false if (selectedId === 'action:add-mcp') { if (workspaceId) { - router.push(`/workspace/${workspaceId}/dashboard`) + router.push(localizeHref(locale, `/workspace/${workspaceId}/dashboard`)) } setToolSelectorValue(undefined) return 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 00e862747..2c9b249c0 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 @@ -7,6 +7,9 @@ import { DateTimePicker } from '@/components/ui/datetime-picker' import { SimpleTimePicker } from '@/components/ui/simple-time-picker' import { Slider } from '@/components/ui/slider' import { Switch as UISwitch } from '@/components/ui/switch' +import { useLocale } from 'next-intl' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { formatUtcDate, formatUtcDateTime, @@ -15,6 +18,7 @@ import { } from '@/lib/time-format' import { cn } from '@/lib/utils' import type { SubBlockConfig } from '@/blocks/types' +import { translateWorkflowLabel } from '@/widgets/workflow-labels' import { ChannelSelectorInput, CheckboxList, @@ -239,6 +243,8 @@ function SubBlockDateTimeField({ export const SubBlock = memo( function SubBlock({ blockId, config, isConnecting, disabled = false }: SubBlockProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.workflowEditor const [isValidJson, setIsValidJson] = useState(true) const handleMouseDown = (e: React.MouseEvent) => { @@ -591,7 +597,7 @@ export const SubBlock = memo( /> ) default: - return <div>Unknown input type: {config.type}</div> + return <div>{copy.unknownInputType.replace('{{type}}', config.type)}</div> } } @@ -603,19 +609,20 @@ export const SubBlock = memo( config.type !== 'market-selector' && config.type !== 'order-id-selector' && config.type !== 'trigger-save' + const label = translateWorkflowLabel(locale, config.title ?? '') return ( <div className={cn('space-y-[6px] pt-[2px]')} onMouseDown={handleMouseDown}> {showLabel && ( <Label className='flex items-center gap-1'> - {config.title} + {label} {required && ( <Tooltip> <TooltipTrigger asChild> <span className='cursor-help text-red-500'>*</span> </TooltipTrigger> <TooltipContent side='top'> - <p>This field is required</p> + <p>{copy.requiredField}</p> </TooltipContent> </Tooltip> )} @@ -630,7 +637,7 @@ export const SubBlock = memo( /> </TooltipTrigger> <TooltipContent side='top'> - <p>Invalid JSON</p> + <p>{copy.invalidJson}</p> </TooltipContent> </Tooltip> )} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.tsx index 70466c1d9..00de35de4 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.tsx @@ -1,4 +1,5 @@ import { type CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useLocale } from 'next-intl' import { Handle, type Node, type NodeProps, Position, useStore, useUpdateNodeInternals } from '@xyflow/react' import { Badge } from '@/components/ui/badge' import { Card } from '@/components/ui/card' @@ -24,6 +25,12 @@ import { useWorkflowChannelId, useWorkflowId, } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' +import type { LocaleCode } from '@/i18n/utils' +import { + formatWorkflowTemplate, + getWorkflowLabelCopy, + translateWorkflowLabel, +} from '@/widgets/workflow-labels' import { ActionBar } from './components/action-bar/action-bar' import { ConnectionBlocks } from './components/connection-blocks/connection-blocks' import { useSubBlockValue } from './components/sub-block/hooks/use-sub-block-value' @@ -263,6 +270,7 @@ type WorkflowBlockNode = Node<WorkflowBlockProps, 'workflowBlock'> // Combine both interfaces into a single component - wrapped in memo for performance export const WorkflowBlock = memo( function WorkflowBlock({ id, data, selected }: NodeProps<WorkflowBlockNode>) { + const locale = useLocale() as LocaleCode const { type, config, name, isActive: dataIsActive, isPending } = data // State management @@ -962,7 +970,7 @@ export const WorkflowBlock = memo( {/* Show debug indicator for pending blocks */} {isPending && ( <div className='-top-6 -translate-x-1/2 absolute left-1/2 z-10 transform rounded-t-md bg-yellow-500 px-2 py-0.5 text-white text-xs'> - Next Step + {translateWorkflowLabel(locale, 'Next Step')} </div> )} @@ -1083,7 +1091,7 @@ export const WorkflowBlock = memo( variant='secondary' className='bg-gray-100 text-gray-500 hover:bg-gray-100' > - Locked + {translateWorkflowLabel(locale, 'Locked')} </Badge> )} {isWorkflowSelector && childWorkflowId && ( @@ -1102,11 +1110,13 @@ export const WorkflowBlock = memo( <span className='text-sm'> {childIsDeployed ? isLoadingChildVersion - ? 'Deployed' + ? translateWorkflowLabel(locale, 'Deployed') : childActiveVersion != null - ? `Deployed (v${childActiveVersion})` - : 'Deployed' - : 'Not Deployed'} + ? formatWorkflowTemplate(getWorkflowLabelCopy(locale).deployedWithVersion, { + version: childActiveVersion, + }) + : translateWorkflowLabel(locale, 'Deployed') + : translateWorkflowLabel(locale, 'Not Deployed')} </span> </TooltipContent> </Tooltip> @@ -1116,7 +1126,7 @@ export const WorkflowBlock = memo( variant='secondary' className='bg-gray-100 text-gray-500 hover:bg-gray-100' > - Disabled + {translateWorkflowLabel(locale, 'Disabled')} </Badge> )} </div> @@ -1139,9 +1149,9 @@ export const WorkflowBlock = memo( <div key={conditionRow.id} className='flex items-center gap-2'> <p className='min-w-0 truncate text-muted-foreground capitalize' - title={conditionRow.title} + title={translateWorkflowLabel(locale, conditionRow.title)} > - {conditionRow.title} + {translateWorkflowLabel(locale, conditionRow.title)} </p> <p className='min-w-0 flex-1 truncate text-right' @@ -1168,13 +1178,13 @@ export const WorkflowBlock = memo( const displayValue = subBlock.password ? rawValue === null || rawValue === undefined || rawValue === '' ? '-' - : 'Configured' + : translateWorkflowLabel(locale, 'Configured') : subBlock.type === 'skill-input' ? formatSkillInputValue(rawValue) : formatSubBlockValue(rawValue) if (isJsonCodeSubBlock) { - const jsonTitle = subBlock.title ?? subBlock.id + const jsonTitle = translateWorkflowLabel(locale, subBlock.title ?? subBlock.id) return ( <div key={stableKey} className='flex flex-col gap-1'> <p @@ -1194,9 +1204,9 @@ export const WorkflowBlock = memo( > <p className='min-w-0 truncate text-muted-foreground' - title={jsonRow.title} + title={translateWorkflowLabel(locale, jsonRow.title)} > - {jsonRow.title} + {translateWorkflowLabel(locale, jsonRow.title)} </p> <p className='min-w-0 flex-1 truncate text-right' @@ -1215,9 +1225,9 @@ export const WorkflowBlock = memo( <div key={stableKey} className='flex items-center gap-2'> <p className='min-w-0 truncate text-muted-foreground capitalize' - title={subBlock.title ?? subBlock.id} + title={translateWorkflowLabel(locale, subBlock.title ?? subBlock.id)} > - {subBlock.title ?? subBlock.id} + {translateWorkflowLabel(locale, subBlock.title ?? subBlock.id)} </p> <p className='min-w-0 flex-1 truncate text-right' @@ -1232,9 +1242,9 @@ export const WorkflowBlock = memo( <div className='flex items-center gap-2'> <p className='min-w-0 truncate text-muted-foreground capitalize' - title='error' + title={translateWorkflowLabel(locale, 'error')} > - error + {translateWorkflowLabel(locale, 'error')} </p> <p className='min-w-0 flex-1 truncate text-right' title='-'> - 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 95ade2650..04abb7e35 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 @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, Pencil } from 'lucide-react' import { Panel } from '@xyflow/react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -24,6 +25,8 @@ import { buildTriggerEditingLayout, getTriggerAwareSubBlockStableKey, } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/trigger-editing-layout' +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' interface NodeEditorPanelProps { selectedNodeId: string | null @@ -33,19 +36,29 @@ type LoopType = 'for' | 'forEach' | 'while' | 'doWhile' type ParallelType = 'count' | 'collection' type SubflowNodeType = 'loop' | 'parallel' -const LOOP_TYPE_OPTIONS: Array<{ value: LoopType; label: string }> = [ - { value: 'for', label: 'For Loop' }, - { value: 'forEach', label: 'For Each' }, - { value: 'while', label: 'While Loop' }, - { value: 'doWhile', label: 'Do While Loop' }, -] +type WorkflowEditorCopy = ReturnType<typeof getPublicCopy>['workspace']['widgets']['workflowEditor'] -const PARALLEL_TYPE_OPTIONS: Array<{ value: ParallelType; label: string }> = [ - { value: 'count', label: 'Parallel Count' }, - { value: 'collection', label: 'Parallel Each' }, -] +function getLoopTypeOptions(copy: WorkflowEditorCopy): Array<{ value: LoopType; label: string }> { + return [ + { value: 'for', label: copy.forLoop }, + { value: 'forEach', label: copy.forEachLoop }, + { value: 'while', label: copy.whileLoop }, + { value: 'doWhile', label: copy.doWhileLoop }, + ] +} + +function getParallelTypeOptions( + copy: WorkflowEditorCopy +): Array<{ value: ParallelType; label: string }> { + return [ + { value: 'count', label: copy.parallelCount }, + { value: 'collection', label: copy.parallelEach }, + ] +} export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.workflowEditor const userPermissions = useUserPermissionsContext() const selectedBlock = useBlock(selectedNodeId ?? '') const selectedLoop = useLoop(selectedNodeId ?? '') @@ -336,13 +349,9 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { }) }, [blockConfig, selectedBlock, shouldDisableWrite]) - const emptyStateMessage = useMemo(() => { - if (isTriggerConfigurationView) { - return 'This trigger has no editable fields in the panel.' - } - - return 'No editable fields for this block.' - }, [isTriggerConfigurationView]) + const emptyStateMessage = isTriggerConfigurationView + ? copy.triggerNoEditableFields + : copy.blockNoEditableFields if (!selectedNodeId) return null @@ -357,7 +366,7 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { onWheel={stopPanelEvent} onTouchStart={stopPanelEvent} > - <div className='text-sm'>Node not found</div> + <div className='text-sm'>{copy.nodeNotFound}</div> </Panel> ) } @@ -376,7 +385,7 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { onTouchStart={stopPanelEvent} > <div className='rounded-md border border-dashed p-3 text-muted-foreground text-xs'> - Missing block configuration for `{selectedBlock.type}`. + {formatTemplate(copy.missingBlockConfiguration, { type: selectedBlock.type })} </div> </Panel> ) @@ -448,7 +457,7 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { className='h-6 w-6 bg-transparent' onClick={isRenaming ? handleSaveRename : handleStartRename} disabled={shouldDisableWrite} - aria-label={isRenaming ? 'Save name' : 'Rename node'} + aria-label={isRenaming ? copy.saveName : copy.renameNode} > {isRenaming ? ( <Check className='h-[14px] w-[14px]' /> @@ -464,7 +473,7 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { <div className='space-y-4'> <div className='space-y-1'> <Label className='font-medium text-muted-foreground text-xs'> - {selectedBlock.type === 'loop' ? 'Loop Type' : 'Parallel Type'} + {selectedBlock.type === 'loop' ? copy.loopTypeLabel : copy.parallelTypeLabel} </Label> <Select value={subflowCurrentType || undefined} @@ -472,16 +481,17 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { disabled={shouldDisableWrite} > <SelectTrigger> - <SelectValue placeholder='Select type' /> + <SelectValue placeholder={copy.selectType} /> </SelectTrigger> <SelectContent> - {(selectedBlock.type === 'loop' ? LOOP_TYPE_OPTIONS : PARALLEL_TYPE_OPTIONS).map( - (option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ) - )} + {(selectedBlock.type === 'loop' + ? getLoopTypeOptions(copy) + : getParallelTypeOptions(copy) + ).map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} </SelectContent> </Select> </div> @@ -489,7 +499,9 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { {isSubflowCountMode ? ( <div className='space-y-1'> <Label className='font-medium text-muted-foreground text-xs'> - {selectedBlock.type === 'loop' ? 'Loop Iterations' : 'Parallel Executions'} + {selectedBlock.type === 'loop' + ? copy.loopIterations + : copy.parallelExecutions} </Label> <Input type='text' @@ -506,17 +518,17 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { placeholder='5' /> <p className='text-[11px] text-muted-foreground'> - Enter a value between 1 and {subflowMaxIterations} + {formatTemplate(copy.enterValueBetween, { max: subflowMaxIterations })} </p> </div> ) : ( <div className='space-y-1'> <Label className='font-medium text-muted-foreground text-xs'> {isSubflowConditionMode - ? 'While Condition' + ? copy.whileCondition : selectedBlock.type === 'loop' - ? 'Collection Items' - : 'Parallel Items'} + ? copy.collectionItems + : copy.parallelItems} </Label> <Textarea value={subflowEditorValue} @@ -574,7 +586,7 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { onClick={handleToggleAdvancedFields} className='flex items-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-muted-foreground hover:text-foreground' > - {displayAdvancedOptions ? 'Hide additional fields' : 'Show additional fields'} + {displayAdvancedOptions ? copy.hideAdditionalFields : copy.showAdditionalFields} <ChevronDown className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`} /> @@ -586,7 +598,7 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { <div className='flex items-center gap-[10px] pt-[4px]'> <div className='h-px flex-1 border-border border-t border-dashed' /> <span className='whitespace-nowrap font-medium text-[13px] text-muted-foreground'> - Additional fields + {copy.additionalFields} </span> <div className='h-px flex-1 border-border border-t border-dashed' /> </div> diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.test.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.test.tsx index 76bf59810..1f0aa15c6 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.test.tsx @@ -1,6 +1,10 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { useLocaleMock } = vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'en'), +})) vi.mock('@xyflow/react', () => ({ Handle: ({ id, type, position }: { id: string; type: string; position: string }) => @@ -22,9 +26,17 @@ vi.mock('@/blocks', () => ({ getBlock: () => undefined, })) +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + import { PreviewNode } from './preview-node' describe('PreviewNode', () => { + afterEach(() => { + useLocaleMock.mockReturnValue('en') + }) + it('renders canonical read-only node chrome and handles for regular blocks', () => { const markup = renderToStaticMarkup( createElement(PreviewNode as any, { @@ -128,6 +140,57 @@ describe('PreviewNode', () => { expect(markup).toContain('data-handle-position="right"') }) + it('translates preview labels with the active locale', () => { + useLocaleMock.mockReturnValue('zh-CN') + + const markup = renderToStaticMarkup( + createElement(PreviewNode as any, { + id: 'agent-localized', + data: { + type: 'agent', + name: 'Agent Localized', + config: { + category: 'blocks', + bgColor: '#00ccff', + icon: (props: any) => createElement('svg', props), + subBlocks: [ + { + id: 'systemPrompt', + title: 'System Prompt', + type: 'long-input', + layout: 'full', + }, + { + id: 'workflowTools', + title: 'Tools', + type: 'short-input', + layout: 'full', + }, + { + id: 'responseFormat', + title: 'Response Format:', + type: 'short-input', + layout: 'full', + }, + ], + }, + subBlockValues: { + systemPrompt: { value: 'hello' }, + workflowTools: { value: 'a,b,c' }, + responseFormat: { value: 'json' }, + }, + readOnly: true, + isPreview: true, + }, + }) + ) + + expect(markup).toContain('系统提示词') + expect(markup).toContain('工具') + expect(markup).toContain('响应格式') + expect(markup).not.toContain('System Prompt') + }) + it('filters conditional preview rows before rendering duplicate subblock ids', () => { const markup = renderToStaticMarkup( createElement(PreviewNode as any, { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx index bda9f2e75..4c26c81ae 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx @@ -1,4 +1,5 @@ import { memo, useMemo } from 'react' +import { useLocale } from 'next-intl' import { Handle, type NodeProps, Position } from '@xyflow/react' import { getIconTileStyle } from '@/lib/ui/icon-colors' import { cn } from '@/lib/utils' @@ -7,6 +8,8 @@ import type { SubBlockConfig } from '@/blocks/types' import { buildSubBlockRows } from '@/lib/workflows/sub-block-rows' import { getPreviewDiffClasses } from './preview-diff' import type { PreviewCanvasNode } from './preview-payload-adapter' +import type { LocaleCode } from '@/i18n/utils' +import { translateWorkflowLabel } from '@/widgets/workflow-labels' function extractSubBlockValue(entry: unknown): unknown { if (entry && typeof entry === 'object' && 'value' in entry) { @@ -65,6 +68,7 @@ function getPreviewSubBlockStableKey( } export const PreviewNode = memo(function PreviewNode({ data }: NodeProps<PreviewCanvasNode>) { + const locale = useLocale() as LocaleCode const blockConfig = useMemo(() => getBlock(data.type) ?? data.config, [data.type, data.config]) const Icon = blockConfig.icon const isEnabled = data.blockState?.enabled ?? true @@ -134,6 +138,7 @@ export const PreviewNode = memo(function PreviewNode({ data }: NodeProps<Preview {previewSubBlocks.map((subBlock, index) => { const rawValue = extractSubBlockValue(previewStateRaw[subBlock.id]) const displayValue = formatPreviewValue(rawValue) + const label = translateWorkflowLabel(locale, subBlock.title ?? subBlock.id) return ( <div key={getPreviewSubBlockStableKey(data.type, subBlock, previewStateRaw, index)} @@ -141,9 +146,9 @@ export const PreviewNode = memo(function PreviewNode({ data }: NodeProps<Preview > <p className='min-w-0 truncate text-[11px] text-muted-foreground capitalize' - title={subBlock.title ?? subBlock.id} + title={label} > - {subBlock.title ?? subBlock.id} + {label} </p> <p className='min-w-0 flex-1 truncate text-right text-[11px]' title={displayValue}> {displayValue} @@ -153,7 +158,9 @@ export const PreviewNode = memo(function PreviewNode({ data }: NodeProps<Preview })} {showInputHandle && ( <div className='flex items-center gap-2'> - <p className='min-w-0 truncate text-[11px] text-muted-foreground capitalize'>error</p> + <p className='min-w-0 truncate text-[11px] text-muted-foreground capitalize'> + {translateWorkflowLabel(locale, 'error')} + </p> </div> )} </div> diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-subflow.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-subflow.tsx index 174b98575..f33f69fda 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-subflow.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-subflow.tsx @@ -1,17 +1,22 @@ import { memo } from 'react' import { RepeatIcon, SplitIcon } from 'lucide-react' +import { useLocale } from 'next-intl' import { Handle, type NodeProps, Position } from '@xyflow/react' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import { getPreviewDiffClasses } from './preview-diff' import type { PreviewCanvasSubflowNode } from './preview-payload-adapter' function PreviewSubflowInner({ data }: NodeProps<PreviewCanvasSubflowNode>) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.workflowEditor const { name, width, height, enabled, kind } = data const isLoop = kind === 'loop' const BlockIcon = isLoop ? RepeatIcon : SplitIcon const iconBackground = isLoop ? '#2FB3FF' : '#FEE12B' - const blockName = name || (isLoop ? 'Loop' : 'Parallel') + const blockName = name || (isLoop ? copy.loop : copy.parallel) const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source' const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source' @@ -54,7 +59,7 @@ function PreviewSubflowInner({ data }: NodeProps<PreviewCanvasSubflowNode>) { <div className='relative h-[calc(100%-41px)] p-4' /> <div className='-translate-y-1/2 absolute top-1/2 left-4 inline-flex items-center rounded-md border border-border bg-background px-3 py-1 text-xs'> - Start + {copy.start} <Handle type='source' position={Position.Right} @@ -74,7 +79,7 @@ function PreviewSubflowInner({ data }: NodeProps<PreviewCanvasSubflowNode>) { className='!h-2 !w-2 !border-none !bg-transparent !opacity-0' style={{ left: -8, top: '50%', transform: 'translateY(-50%)' }} /> - End + {copy.end} </div> <Handle type='source' diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.test.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.test.tsx index fac2fc358..d5b8d05e3 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.test.tsx @@ -1,9 +1,17 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { ReadOnlyNodeEditorPanel } from './read-only-node-editor-panel' import type { WorkflowState } from '@/stores/workflows/workflow/types' +const { useLocaleMock } = vi.hoisted(() => ({ + useLocaleMock: vi.fn(() => 'zh-CN'), +})) + +vi.mock('next-intl', () => ({ + useLocale: useLocaleMock, +})) + function createWorkflowState(): WorkflowState { return { blocks: { @@ -34,7 +42,7 @@ describe('ReadOnlyNodeEditorPanel', () => { }) ) - expect(markup).toContain('Select a block to view its preview details.') + expect(markup).toContain('选择一个块以查看其预览详情。') }) it('renders missing-node state when selected id is not present', () => { @@ -45,8 +53,8 @@ describe('ReadOnlyNodeEditorPanel', () => { }) ) - expect(markup).toContain('Node not found') - expect(markup).toContain('no longer available') + expect(markup).toContain('未找到节点') + expect(markup).toContain('已不可用') }) it('renders inspector header and resolved read-only panel for selected node', () => { @@ -57,7 +65,7 @@ describe('ReadOnlyNodeEditorPanel', () => { }) ) - expect(markup).toContain('Preview Inspector') + expect(markup).toContain('预览检查器') expect(markup).toContain('Agent One') expect(markup).toContain('prompt') expect(markup).toContain('hello') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.tsx index a64066561..68444ae90 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.tsx @@ -1,4 +1,7 @@ import { useMemo } from 'react' +import { useLocale } from 'next-intl' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { resolveReadOnlyPreviewPanel } from './preview-panel-registry' @@ -11,6 +14,8 @@ export function ReadOnlyNodeEditorPanel({ selectedNodeId, workflowState, }: ReadOnlyNodeEditorPanelProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.workflowEditor const selectedBlock = useMemo(() => { if (!selectedNodeId) { return null @@ -23,7 +28,9 @@ export function ReadOnlyNodeEditorPanel({ return ( <aside className='w-80 shrink-0 border-border border-l bg-background/95 p-4'> <div className='flex h-full items-center justify-center text-center'> - <p className='text-muted-foreground text-sm'>Select a block to view its preview details.</p> + <p className='text-muted-foreground text-sm'> + {copy.selectBlockToViewPreviewDetails} + </p> </div> </aside> ) @@ -33,8 +40,8 @@ export function ReadOnlyNodeEditorPanel({ return ( <aside className='w-80 shrink-0 border-border border-l bg-background/95 p-4'> <div className='space-y-2'> - <h3 className='font-medium text-sm'>Node not found</h3> - <p className='text-muted-foreground text-xs'>The selected node is no longer available.</p> + <h3 className='font-medium text-sm'>{copy.nodeNotFound}</h3> + <p className='text-muted-foreground text-xs'>{copy.selectedNodeUnavailable}</p> </div> </aside> ) @@ -46,7 +53,9 @@ export function ReadOnlyNodeEditorPanel({ <aside className='w-80 shrink-0 border-border border-l bg-background/95 p-4'> <div className='space-y-4'> <header className='space-y-1'> - <p className='text-muted-foreground text-xs uppercase tracking-wide'>Preview Inspector</p> + <p className='text-muted-foreground text-xs uppercase tracking-wide'> + {copy.previewInspector} + </p> <h3 className='line-clamp-2 font-medium text-sm'>{selectedBlock.name}</h3> </header> diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx index 43bcc63fd..92ab5777e 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' import { Background, ConnectionLineType, @@ -12,11 +13,11 @@ import { useReactFlow, } from '@xyflow/react' import '@xyflow/react/dist/style.css' -import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { TriggerUtils } from '@/lib/workflows/triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { getBlock } from '@/blocks' +import { localizeHref, type LocaleCode } from '@/i18n/utils' import { useWorkflowEditorActions } from '@/hooks/workflow/use-workflow-editor-actions' import { useOptionalWorkflowSession } from '@/lib/yjs/workflow-session-host' import { useStreamCleanup } from '@/hooks/use-stream-cleanup' @@ -221,6 +222,7 @@ const WorkflowCanvas = React.memo( // Hooks const router = useRouter() + const locale = useLocale() as LocaleCode const { workspaceId, workflowId } = useWorkflowRoute() const resolvedChannelId = useMemo(() => channelId ?? DEFAULT_WORKFLOW_CHANNEL_ID, [channelId]) const reactFlowId = useMemo(() => `workflow-${resolvedChannelId}`, [resolvedChannelId]) @@ -444,8 +446,6 @@ const WorkflowCanvas = React.memo( ) // Auto-layout handler - now uses frontend auto layout for immediate updates - const { data: session } = useSession() - const handleAutoLayout = useCallback(async () => { if (Object.keys(blocks).length === 0) return @@ -458,7 +458,6 @@ const WorkflowCanvas = React.memo( const result = await applyAutoLayoutAndUpdateStore({ workflowId: activeWorkflowId!, channelId: resolvedChannelId, - undoUserId: session?.user?.id, }) if (result.success) { @@ -1144,7 +1143,7 @@ const WorkflowCanvas = React.memo( // If no workflows exist after loading, redirect to workspace root if (workflowIds.length === 0) { logger.info('No workflows found, redirecting to workspace root') - router.replace(`/workspace/${workspaceId}/dashboard`) + router.replace(localizeHref(locale, `/workspace/${workspaceId}/dashboard`)) return } @@ -1159,10 +1158,10 @@ const WorkflowCanvas = React.memo( }) if (workspaceWorkflows.length > 0) { - router.replace(`/workspace/${workspaceId}/dashboard`) + router.replace(localizeHref(locale, `/workspace/${workspaceId}/dashboard`)) } else { // No valid workflows for this workspace, redirect to workspace root - router.replace(`/workspace/${workspaceId}/dashboard`) + router.replace(localizeHref(locale, `/workspace/${workspaceId}/dashboard`)) } return } @@ -1174,7 +1173,7 @@ const WorkflowCanvas = React.memo( `Workflow ${currentId} belongs to workspace ${currentWorkflow.workspaceId}, not ${workspaceId}` ) // Redirect to the correct workspace for this workflow - router.replace(`/workspace/${currentWorkflow.workspaceId}/dashboard`) + router.replace(localizeHref(locale, `/workspace/${currentWorkflow.workspaceId}/dashboard`)) return } } @@ -1188,6 +1187,7 @@ const WorkflowCanvas = React.memo( workspaceId, router, hasWorkflowsInitiallyLoaded, + locale, ]) const blockConfigCache = useRef(new Map()) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-toolbar/workflow-toolbar.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-toolbar/workflow-toolbar.tsx index 09e465164..5194fdd07 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-toolbar/workflow-toolbar.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-toolbar/workflow-toolbar.tsx @@ -8,6 +8,7 @@ import { useMemo, useState, } from 'react' +import { useLocale } from 'next-intl' import { ChevronDown, Search } from 'lucide-react' import { DropdownMenu, @@ -33,6 +34,8 @@ import { } from '@/lib/workflows/trigger-utils' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { BlockConfig } from '@/blocks/types' +import type { LocaleCode } from '@/i18n/utils' +import { formatWorkflowTemplate, getWorkflowToolbarCopy } from '@/widgets/workflow-labels' import { widgetHeaderButtonGroupClassName, widgetHeaderControlClassName, @@ -58,8 +61,6 @@ interface ToolbarListData { } const DEFAULT_PROVIDER_AVAILABILITY: ProviderAvailability = {} - -const FALLBACK_TEXT = 'Select a workspace to browse blocks' const DROPDOWN_MAX_HEIGHT = '20rem' const DROPDOWN_VIEWPORT_HEIGHT = '14.0rem' @@ -114,6 +115,8 @@ function useToolbarList( } export function WorkflowToolbar({ workspaceId, toolbarScopeId }: WorkflowToolbarProps) { + const locale = useLocale() as LocaleCode + const copy = getWorkflowToolbarCopy(locale) const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>( DEFAULT_PROVIDER_AVAILABILITY ) @@ -150,7 +153,7 @@ export function WorkflowToolbar({ workspaceId, toolbarScopeId }: WorkflowToolbar }, [providerIds]) if (!workspaceId) { - return <span className='text-muted-foreground text-xs'>{FALLBACK_TEXT}</span> + return <span className='text-muted-foreground text-xs'>{copy.selectWorkspace}</span> } return ( @@ -161,7 +164,7 @@ export function WorkflowToolbar({ workspaceId, toolbarScopeId }: WorkflowToolbar dispatchToolbarAddBlock(request, toolbarScopeId) }} > - <ToolbarDropdownGroup providerAvailability={providerAvailability} /> + <ToolbarDropdownGroup providerAvailability={providerAvailability} copy={copy} /> </ToolbarAddBlockProvider> </WorkspacePermissionsProvider> </TooltipProvider> @@ -170,8 +173,10 @@ export function WorkflowToolbar({ workspaceId, toolbarScopeId }: WorkflowToolbar function ToolbarDropdownGroup({ providerAvailability, + copy, }: { providerAvailability: ProviderAvailability + copy: ReturnType<typeof getWorkflowToolbarCopy> }) { const [blockSearch, setBlockSearch] = useState('') const [toolSearch, setToolSearch] = useState('') @@ -183,18 +188,29 @@ function ToolbarDropdownGroup({ return ( <div className={widgetHeaderButtonGroupClassName()}> - <ToolbarDropdown label='Blocks' searchValue={blockSearch} onSearchChange={setBlockSearch}> - <ToolbarDropdownContent data={blockData} mode='blocks' /> + <ToolbarDropdown + label={copy.blocks} + copy={copy} + searchValue={blockSearch} + onSearchChange={setBlockSearch} + > + <ToolbarDropdownContent data={blockData} mode='blocks' copy={copy} /> </ToolbarDropdown> - <ToolbarDropdown label='Tools' searchValue={toolSearch} onSearchChange={setToolSearch}> - <ToolbarDropdownContent data={toolData} mode='tools' /> + <ToolbarDropdown + label={copy.tools} + copy={copy} + searchValue={toolSearch} + onSearchChange={setToolSearch} + > + <ToolbarDropdownContent data={toolData} mode='tools' copy={copy} /> </ToolbarDropdown> <ToolbarDropdown - label='Triggers' + label={copy.triggers} + copy={copy} searchValue={triggerSearch} onSearchChange={setTriggerSearch} > - <ToolbarDropdownContent data={triggerData} mode='triggers' /> + <ToolbarDropdownContent data={triggerData} mode='triggers' copy={copy} /> </ToolbarDropdown> </div> ) @@ -202,12 +218,19 @@ function ToolbarDropdownGroup({ interface ToolbarDropdownProps { label: string + copy: ReturnType<typeof getWorkflowToolbarCopy> searchValue: string onSearchChange: (value: string) => void children: ReactNode } -function ToolbarDropdown({ label, searchValue, onSearchChange, children }: ToolbarDropdownProps) { +function ToolbarDropdown({ + label, + copy, + searchValue, + onSearchChange, + children, +}: ToolbarDropdownProps) { const handleSearchInputKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => { if (event.key === 'Escape') return @@ -216,7 +239,7 @@ function ToolbarDropdown({ label, searchValue, onSearchChange, children }: Toolb } }, []) - const tooltipText = `Browse ${label.toLowerCase()}` + const tooltipText = formatWorkflowTemplate(copy.browseLabel, { label }) return ( <DropdownMenu modal={false}> @@ -257,7 +280,7 @@ function ToolbarDropdown({ label, searchValue, onSearchChange, children }: Toolb <Input value={searchValue} onChange={(event) => onSearchChange(event.target.value)} - placeholder={`Search ${label.toLowerCase()}...`} + placeholder={formatWorkflowTemplate(copy.searchPlaceholder, { label })} 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' @@ -276,11 +299,14 @@ function ToolbarDropdown({ label, searchValue, onSearchChange, children }: Toolb function ToolbarDropdownContent({ data, mode, + copy, }: { data: ToolbarListData mode: ToolbarMode + copy: ReturnType<typeof getWorkflowToolbarCopy> }) { const { regularBlocks, toolBlocks, triggerBlocks, includeSpecialBlocks } = data + const modeLabel = mode === 'blocks' ? copy.blocks : mode === 'tools' ? copy.tools : copy.triggers const hasResults = (() => { if (mode === 'blocks') return regularBlocks.length > 0 || includeSpecialBlocks @@ -291,16 +317,18 @@ function ToolbarDropdownContent({ return ( <ScrollArea className='h-full w-full px-2 py-2' - style={{ height: DROPDOWN_VIEWPORT_HEIGHT, maxHeight: `calc(${DROPDOWN_MAX_HEIGHT} - 4rem)` }} - onWheelCapture={(event) => event.stopPropagation()} - > + style={{ height: DROPDOWN_VIEWPORT_HEIGHT, maxHeight: `calc(${DROPDOWN_MAX_HEIGHT} - 4rem)` }} + onWheelCapture={(event) => event.stopPropagation()} + > {!hasResults && ( - <p className='px-2 py-4 text-center text-muted-foreground text-xs'>No {mode} found.</p> + <p className='px-2 py-4 text-center text-muted-foreground text-xs'> + {formatWorkflowTemplate(copy.noResults, { label: modeLabel })} + </p> )} {mode === 'blocks' && regularBlocks.length > 0 && ( <div className='space-y-1 pb-2'> - <SectionLabel title='Blocks' /> + <SectionLabel title={copy.blocks} /> {regularBlocks.map((block) => ( <DropdownMenuItem key={block.type} className='p-0 focus:bg-transparent'> <ToolbarBlock config={block} /> @@ -311,7 +339,7 @@ function ToolbarDropdownContent({ {mode === 'blocks' && includeSpecialBlocks && ( <div className='space-y-1 pb-2'> - <SectionLabel title='Special' /> + <SectionLabel title={copy.special} /> <DropdownMenuItem className='p-0 focus:bg-transparent'> <LoopToolbarItem /> </DropdownMenuItem> @@ -323,7 +351,7 @@ function ToolbarDropdownContent({ {mode === 'tools' && toolBlocks.length > 0 && ( <div className='space-y-1 pb-2'> - <SectionLabel title='Tools' /> + <SectionLabel title={copy.tools} /> {toolBlocks.map((block) => ( <DropdownMenuItem key={block.type} className='p-0 focus:bg-transparent'> <ToolbarBlock config={block} /> @@ -334,7 +362,7 @@ function ToolbarDropdownContent({ {mode === 'triggers' && triggerBlocks.length > 0 && ( <div className='space-y-1 pb-2'> - <SectionLabel title='Triggers' /> + <SectionLabel title={copy.triggers} /> {triggerBlocks.map((block) => ( <DropdownMenuItem key={block.type} className='p-0 focus:bg-transparent'> <ToolbarBlock diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/index.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/index.tsx index 8e7ad595e..827fd9c1c 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/index.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/index.tsx @@ -146,6 +146,10 @@ const WorkflowEditorWidgetBody = ({ } if (!resolvedWorkflowId) { + if (resolvedPairColor !== 'gray') { + return <WidgetStateMessage message='This color has no shared workflow selected yet.' /> + } + return ( <div className='flex h-full w-full items-center justify-center '> <LoadingAgent size='md' /> diff --git a/apps/tradinggoose/widgets/widgets/empty/index.tsx b/apps/tradinggoose/widgets/widgets/empty/index.tsx index c28abfdf5..0e475748f 100644 --- a/apps/tradinggoose/widgets/widgets/empty/index.tsx +++ b/apps/tradinggoose/widgets/widgets/empty/index.tsx @@ -1,4 +1,7 @@ +'use client' + import { MinusCircle } from 'lucide-react' +import { useLocale } from 'next-intl' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { @@ -9,6 +12,8 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/empty' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' import { WidgetSelector } from '@/widgets/widgets/components/widget-selector' @@ -16,39 +21,49 @@ type EmptyWidgetProps = WidgetComponentProps & { onWidgetChange?: (widgetKey: string) => void } -const EmptyBody = ({ widget, onWidgetChange }: EmptyWidgetProps) => ( - <Empty className='p-6'> - <EmptyHeader> - <EmptyMedia variant='default'> - <Avatar className='size-12 border border-border/60 '> - <AvatarFallback className='bg-transparent'> - <MinusCircle className='size-5 text-muted-foreground' aria-hidden='true' /> - </AvatarFallback> - </Avatar> - </EmptyMedia> - <EmptyTitle> - {widget?.key && widget?.key !== 'empty' ? 'Empty Widget' : 'No widget selected'} - </EmptyTitle> - <EmptyDescription> - {widget?.key && widget?.key !== 'empty' - ? 'This widget is currently empty, choose another widget to continue.' - : 'Pick a widget from the gallery to start using this panel.'} - </EmptyDescription> - </EmptyHeader> - <EmptyContent> - <WidgetSelector - currentKey={widget?.key} - onSelect={(key) => onWidgetChange?.(key)} - disabled={!onWidgetChange} - renderTrigger={({ disabled }) => ( - <Button size='sm' variant='outline' disabled={disabled} type='button'> - Choose Widget - </Button> - )} - /> - </EmptyContent> - </Empty> -) +const EmptyHeaderLabel = () => { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.empty + + return <span className='text-muted-foreground text-xs'>{copy.noWidgetSelected}</span> +} + +const EmptyBody = ({ widget, onWidgetChange }: EmptyWidgetProps) => { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.empty + + const isEmptyWidget = widget?.key && widget.key !== 'empty' + + return ( + <Empty className='p-6'> + <EmptyHeader> + <EmptyMedia variant='default'> + <Avatar className='size-12 border border-border/60 '> + <AvatarFallback className='bg-transparent'> + <MinusCircle className='size-5 text-muted-foreground' aria-hidden='true' /> + </AvatarFallback> + </Avatar> + </EmptyMedia> + <EmptyTitle>{isEmptyWidget ? copy.emptyWidget : copy.noWidgetSelected}</EmptyTitle> + <EmptyDescription> + {isEmptyWidget ? copy.emptyWidgetDescription : copy.noWidgetDescription} + </EmptyDescription> + </EmptyHeader> + <EmptyContent> + <WidgetSelector + currentKey={widget?.key} + onSelect={(key) => onWidgetChange?.(key)} + disabled={!onWidgetChange} + renderTrigger={({ disabled }) => ( + <Button size='sm' variant='outline' disabled={disabled} type='button'> + {copy.chooseWidget} + </Button> + )} + /> + </EmptyContent> + </Empty> + ) +} export const emptyWidget: DashboardWidgetDefinition = { key: 'empty', @@ -58,6 +73,6 @@ export const emptyWidget: DashboardWidgetDefinition = { description: 'Placeholder state shown when the panel does not have a widget assigned.', component: EmptyBody, renderHeader: () => ({ - center: <span className='text-muted-foreground text-xs'>No widget selected</span>, + center: <EmptyHeaderLabel />, }), } diff --git a/apps/tradinggoose/widgets/widgets/entity_review/resolve-entity-id.ts b/apps/tradinggoose/widgets/widgets/entity_review/resolve-entity-id.ts index 3c98754f9..e30c57ca1 100644 --- a/apps/tradinggoose/widgets/widgets/entity_review/resolve-entity-id.ts +++ b/apps/tradinggoose/widgets/widgets/entity_review/resolve-entity-id.ts @@ -1,9 +1,8 @@ /** * Generic resolver for entity IDs from pairContext or widget params. * - * Checks `pairContext[key]` first (when the key exists on the object), then - * falls back to `params[key]`. Returns `null` when the value is missing, - * not a string, or a blank/whitespace-only string. + * When pair context is provided, linked widgets resolve only from that shared + * context. Otherwise the value is read from local widget params. */ export function resolveEntityId( key: string, @@ -15,8 +14,8 @@ export function resolveEntityId( pairContext?: Record<string, unknown> | null } ): string | null { - if (pairContext && Object.hasOwn(pairContext, key)) { - const value = pairContext[key] + if (pairContext) { + const value = Object.hasOwn(pairContext, key) ? pairContext[key] : null return typeof value === 'string' && value.trim().length > 0 ? value : null } diff --git a/apps/tradinggoose/widgets/widgets/entity_review/review-target-utils.test.ts b/apps/tradinggoose/widgets/widgets/entity_review/review-target-utils.test.ts index 7e8744527..7aef09f84 100644 --- a/apps/tradinggoose/widgets/widgets/entity_review/review-target-utils.test.ts +++ b/apps/tradinggoose/widgets/widgets/entity_review/review-target-utils.test.ts @@ -5,7 +5,7 @@ import { } from './review-target-utils' describe('review target utils', () => { - it('falls back to persisted params when pair context only contains workflow state', () => { + it('ignores persisted params when linked pair context is present', () => { expect( readEntitySelectionState({ pairContext: { @@ -21,31 +21,18 @@ describe('review target utils', () => { }, legacyIdKey: 'indicatorId', }) - ).toMatchObject({ - legacyEntityId: 'indicator-1', - reviewSessionId: 'review-1', - reviewEntityId: 'indicator-1', - descriptor: { - workspaceId: 'workspace-1', - entityKind: 'indicator', - entityId: 'indicator-1', - reviewSessionId: 'review-1', - yjsSessionId: 'review-1', - }, + ).toEqual({ + legacyEntityId: null, + reviewSessionId: null, + reviewEntityId: null, + reviewDraftSessionId: null, + descriptor: null, }) }) - it('prefers explicit pair review target fields when they are present', () => { + it('reads review target descriptor from widget params in gray mode', () => { expect( readReviewTargetDescriptor({ - pairContext: { - workflowId: 'workflow-1', - reviewTarget: { - reviewSessionId: 'review-pair', - reviewEntityKind: 'indicator', - reviewEntityId: 'indicator-pair', - }, - }, params: { reviewSessionId: 'review-param', reviewEntityKind: 'indicator', @@ -55,8 +42,8 @@ describe('review target utils', () => { }) ).toMatchObject({ entityKind: 'indicator', - entityId: 'indicator-pair', - reviewSessionId: 'review-pair', + entityId: 'indicator-param', + reviewSessionId: 'review-param', }) }) }) diff --git a/apps/tradinggoose/widgets/widgets/entity_review/review-target-utils.ts b/apps/tradinggoose/widgets/widgets/entity_review/review-target-utils.ts index ea5f8a25e..fd0621da4 100644 --- a/apps/tradinggoose/widgets/widgets/entity_review/review-target-utils.ts +++ b/apps/tradinggoose/widgets/widgets/entity_review/review-target-utils.ts @@ -10,7 +10,7 @@ import type { ReviewTargetDescriptor, } from '@/lib/copilot/review-sessions/types' import { normalizeOptionalString } from '@/lib/utils' -import type { PairColorContext, PairReviewTarget } from '@/stores/dashboard/pair-store' +import type { PairColorContext } from '@/stores/dashboard/pair-store' import { REVIEW_TARGET_FIELDS } from '@/widgets/events' import { resolveEntityId } from '@/widgets/widgets/entity_review/resolve-entity-id' @@ -22,21 +22,6 @@ export interface EntitySelectionState { descriptor: ReviewTargetDescriptor | null } -function getNestedReviewTarget( - pairContext?: PairColorContext | null -): Record<string, unknown> | null { - if (!pairContext || typeof pairContext !== 'object') { - return null - } - - const reviewTarget = pairContext.reviewTarget - if (!reviewTarget || typeof reviewTarget !== 'object') { - return null - } - - return reviewTarget as Record<string, unknown> -} - function readOwnNormalizedString( source: Record<string, unknown> | null | undefined, key: string @@ -58,14 +43,8 @@ function resolveReviewField( pairContext?: PairColorContext | null } ): string | null { - const nestedPairTarget = readOwnNormalizedString(getNestedReviewTarget(options.pairContext), key) - if (nestedPairTarget.found) { - return nestedPairTarget.value - } - - const pairField = readOwnNormalizedString(options.pairContext as Record<string, unknown> | null, key) - if (pairField.found) { - return pairField.value + if (options.pairContext) { + return null } return readOwnNormalizedString(options.params ?? null, key).value @@ -198,16 +177,12 @@ export function buildPersistedPairContext(options: { } delete next.reviewTarget - - if (options.descriptor) { - const serialized = serializeReviewTargetDescriptor(options.descriptor) - next.reviewTarget = { - reviewSessionId: serialized.reviewSessionId ?? null, - reviewEntityKind: serialized.reviewEntityKind ?? null, - reviewEntityId: serialized.reviewEntityId ?? null, - reviewDraftSessionId: serialized.reviewDraftSessionId ?? null, - } - } + delete next.reviewSessionId + delete next.reviewEntityKind + delete next.reviewEntityId + delete next.reviewDraftSessionId + delete next.workspaceId + delete next.yjsSessionId return next } diff --git a/apps/tradinggoose/widgets/widgets/entity_review/use-resolved-review-target.test.tsx b/apps/tradinggoose/widgets/widgets/entity_review/use-resolved-review-target.test.tsx index f096b58bb..234d5d54a 100644 --- a/apps/tradinggoose/widgets/widgets/entity_review/use-resolved-review-target.test.tsx +++ b/apps/tradinggoose/widgets/widgets/entity_review/use-resolved-review-target.test.tsx @@ -36,7 +36,13 @@ function flushPromises() { }) } -function HookHarness({ initialPairContext }: { initialPairContext?: PairColorContext | null }) { +function HookHarness({ + initialPairContext, + initialParams = null, +}: { + initialPairContext?: PairColorContext | null + initialParams?: Record<string, unknown> | null +}) { const [pairContext, setPairContext] = useState<PairColorContext | null>( initialPairContext ?? { skillId: 'skill-1', @@ -45,13 +51,14 @@ function HookHarness({ initialPairContext }: { initialPairContext?: PairColorCon const selectionState = readEntitySelectionState({ pairContext, + params: initialParams, legacyIdKey: 'skillId', }) const { descriptor, isResolving, error } = useResolvedReviewTarget({ workspaceId: 'ws-1', entityKind: 'skill', - params: null, + params: initialParams, pairColor: 'red', pairContext, legacyIdKey: 'skillId', @@ -61,7 +68,7 @@ function HookHarness({ initialPairContext }: { initialPairContext?: PairColorCon setPairContext: (_color, context) => { setPairContext({ ...context, - updatedAt: Date.now(), + workflowId: 'workflow-current', }) }, }) @@ -82,11 +89,14 @@ function HookHarness({ initialPairContext }: { initialPairContext?: PairColorCon current ? { ...current, - updatedAt: Date.now(), + workflowId: + current.workflowId === 'workflow-current' + ? 'workflow-next' + : 'workflow-current', } : { skillId: 'skill-1', - updatedAt: Date.now(), + workflowId: 'workflow-current', } ) } @@ -181,12 +191,12 @@ describe('useResolvedReviewTarget', () => { <HookHarness initialPairContext={{ skillId: 'skill-2', - reviewTarget: { - reviewSessionId: 'review-1', - reviewEntityKind: 'skill', - reviewEntityId: 'skill-1', - reviewDraftSessionId: null, - }, + }} + initialParams={{ + reviewSessionId: 'review-1', + reviewEntityKind: 'skill', + reviewEntityId: 'skill-1', + workspaceId: 'ws-1', }} /> ) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/body.test.tsx b/apps/tradinggoose/widgets/widgets/heatmap/components/body.test.tsx new file mode 100644 index 000000000..6474b32f2 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/body.test.tsx @@ -0,0 +1,603 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { type PairColorContext, usePairColorStore } from '@/stores/dashboard/pair-store' +import { PAIR_COLORS, type PairColor } from '@/widgets/pair-colors' +import { HeatmapWidgetBody } from '@/widgets/widgets/heatmap/components/body' + +const mockUseResolvedListings = vi.fn() +const mockUseMarketQuoteSnapshots = vi.fn() +const mockUseOAuthProviderAvailability = vi.fn() +const mockUseOAuthCredentialsByProviderIds = vi.fn() +const mockUseTradingAccounts = vi.fn() +const mockUseTradingPortfolioSnapshot = vi.fn() +const mockUseWatchlists = vi.fn() +const mockHeatmapTreemapChart = vi.fn() +const mockEmitHeatmapParamsChange = vi.fn() + +vi.mock('@/hooks/queries/listing-resolution', () => ({ + useResolvedListings: (...args: unknown[]) => mockUseResolvedListings(...args), +})) + +vi.mock('@/hooks/queries/market-quote-snapshots', () => ({ + useMarketQuoteSnapshots: (...args: unknown[]) => mockUseMarketQuoteSnapshots(...args), +})) + +vi.mock('@/hooks/queries/oauth-provider-availability', () => ({ + useOAuthProviderAvailability: (...args: unknown[]) => mockUseOAuthProviderAvailability(...args), +})) + +vi.mock('@/hooks/queries/oauth-credentials', () => ({ + useOAuthCredentialsByProviderIds: (...args: unknown[]) => + mockUseOAuthCredentialsByProviderIds(...args), +})) + +vi.mock('@/hooks/queries/trading-portfolio', () => ({ + useTradingAccounts: (...args: unknown[]) => mockUseTradingAccounts(...args), + useTradingPortfolioSnapshot: (...args: unknown[]) => mockUseTradingPortfolioSnapshot(...args), +})) + +vi.mock('@/hooks/queries/watchlists', () => ({ + useWatchlists: (...args: unknown[]) => mockUseWatchlists(...args), +})) + +vi.mock('@/widgets/utils/heatmap-params', () => ({ + emitHeatmapParamsChange: (...args: unknown[]) => mockEmitHeatmapParamsChange(...args), + useHeatmapParamsPersistence: vi.fn(), +})) + +vi.mock('@/widgets/widgets/heatmap/components/heatmap-treemap-chart', () => ({ + HeatmapTreemapChart: (props: { items: unknown[]; cappedCount?: number; totalCount?: number }) => { + mockHeatmapTreemapChart(props) + return ( + <div> + heatmap-chart:{props.items.length} + {props.cappedCount + ? ` Showing first ${props.items.length} of ${props.totalCount} listings.` + : ''} + </div> + ) + }, +})) + +const createQueryResult = <T,>(overrides: Partial<T> = {}) => + ({ + data: undefined, + isLoading: false, + isFetching: false, + isPlaceholderData: false, + error: null, + refetch: vi.fn(), + ...overrides, + }) as T + +const createListing = (symbol: string) => ({ + listing_id: symbol, + base_id: '', + quote_id: '', + listing_type: 'default' as const, +}) + +function resetPairContexts() { + usePairColorStore.setState({ + contexts: Object.fromEntries(PAIR_COLORS.map((color) => [color, {}])) as Record< + PairColor, + PairColorContext + >, + }) +} + +describe('HeatmapWidgetBody', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + mockUseResolvedListings.mockReturnValue(createQueryResult({ data: {} })) + mockUseMarketQuoteSnapshots.mockReturnValue(createQueryResult({ data: {} })) + mockUseOAuthProviderAvailability.mockReturnValue( + createQueryResult({ data: { 'alpaca-live': true, 'alpaca-paper': true } }) + ) + mockUseOAuthCredentialsByProviderIds.mockReturnValue( + createQueryResult({ + data: { + 'alpaca-live': [{ id: 'cred-1', name: 'Alpaca Live', provider: 'alpaca-live' }], + }, + }) + ) + mockUseTradingAccounts.mockReturnValue(createQueryResult({ data: [] })) + mockUseTradingPortfolioSnapshot.mockReturnValue( + createQueryResult({ data: undefined, positionListings: [] }) + ) + mockUseWatchlists.mockReturnValue(createQueryResult({ data: [] })) + resetPairContexts() + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('caps watchlist-mode identities before the shared quote and chart pipeline', async () => { + const watchlistItems = Array.from({ length: 201 }, (_, index) => ({ + id: `item-${index}`, + type: 'listing' as const, + listing: createListing(`SYM${index}`), + })) + mockUseWatchlists.mockReturnValue( + createQueryResult({ + data: [ + { + id: 'watchlist-1', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Watchlist', + isSystem: false, + items: watchlistItems, + settings: { showLogo: true, showTicker: true, showDescription: true }, + createdAt: '', + updatedAt: '', + }, + ], + }) + ) + + await act(async () => { + root.render( + <HeatmapWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'heatmap' } as any} + panelId='panel-1' + params={{ + sourceMode: 'watchlist', + marketProvider: 'alpaca', + }} + /> + ) + }) + + expect(container.textContent).toContain('Showing first 200 of 201 listings.') + expect(container.textContent).toContain('heatmap-chart:200') + expect(mockUseMarketQuoteSnapshots).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + provider: 'alpaca', + enabled: true, + refreshKey: null, + items: expect.arrayContaining([ + { + key: 'default|SYM0||', + listing: createListing('SYM0'), + }, + ]), + }) + ) + expect(mockUseMarketQuoteSnapshots.mock.calls.at(-1)?.[0].items).toHaveLength(200) + expect(mockHeatmapTreemapChart.mock.calls.at(-1)?.[0].items).toHaveLength(200) + expect(mockUseWatchlists).toHaveBeenCalledWith('workspace-1') + expect(mockUseOAuthProviderAvailability).toHaveBeenCalledWith(expect.any(Array), false) + }) + + it('does not render stale placeholder watchlist data into the shared chart pipeline', async () => { + mockUseWatchlists.mockReturnValue( + createQueryResult({ + data: [ + { + id: 'watchlist-1', + workspaceId: 'old-workspace', + userId: 'user-1', + name: 'Old Watchlist', + isSystem: false, + items: [ + { + id: 'old-item', + type: 'listing' as const, + listing: createListing('OLD'), + }, + ], + settings: { showLogo: true, showTicker: true, showDescription: true }, + createdAt: '', + updatedAt: '', + }, + ], + isPlaceholderData: true, + }) + ) + + await act(async () => { + root.render( + <HeatmapWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'heatmap' } as any} + panelId='panel-1' + params={{ + sourceMode: 'watchlist', + marketProvider: 'alpaca', + }} + /> + ) + }) + + expect(mockUseMarketQuoteSnapshots.mock.calls.at(-1)?.[0].items).toEqual([]) + expect(mockUseResolvedListings.mock.calls.at(-1)?.[0].listings).toEqual([]) + expect(mockHeatmapTreemapChart).not.toHaveBeenCalled() + }) + + it('does not use portfolio trading provider settings as market quote provider settings', async () => { + mockUseTradingAccounts.mockReturnValue( + createQueryResult({ + data: [{ id: 'account-1', name: 'Paper' }], + }) + ) + mockUseTradingPortfolioSnapshot.mockReturnValue( + createQueryResult({ + positionListings: [ + { + listing: createListing('MSFT'), + grossQuantity: 4, + signedQuantity: 1, + }, + ], + }) + ) + + await act(async () => { + root.render( + <HeatmapWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'heatmap' } as any} + panelId='panel-1' + params={{ + sourceMode: 'portfolio', + tradingProvider: 'alpaca', + accountId: 'account-1', + }} + /> + ) + }) + + expect(mockUseMarketQuoteSnapshots).toHaveBeenLastCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + provider: undefined, + auth: undefined, + providerParams: undefined, + enabled: false, + }) + ) + }) + + it('switches source modes through the same source-neutral chart props', async () => { + mockUseWatchlists.mockReturnValue( + createQueryResult({ + data: [ + { + id: 'watchlist-1', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Watchlist', + isSystem: false, + items: [ + { + id: 'watchlist-item', + type: 'listing' as const, + listing: createListing('AAPL'), + }, + ], + settings: { showLogo: true, showTicker: true, showDescription: true }, + createdAt: '', + updatedAt: '', + }, + ], + }) + ) + mockUseTradingAccounts.mockReturnValue( + createQueryResult({ + data: [{ id: 'account-1', name: 'Paper' }], + }) + ) + mockUseTradingPortfolioSnapshot.mockReturnValue( + createQueryResult({ + positionListings: [ + { + listing: createListing('MSFT'), + grossQuantity: 4, + signedQuantity: 1, + }, + ], + }) + ) + mockUseMarketQuoteSnapshots.mockReturnValue( + createQueryResult({ + data: { + 'default|AAPL||': { + lastPrice: 110, + previousClose: 100, + change: 10, + changePercent: 10, + volume: 20, + volumeUsd: 2200, + }, + 'default|MSFT||': { + lastPrice: 25, + previousClose: 20, + change: 5, + changePercent: 25, + }, + }, + }) + ) + + await act(async () => { + root.render( + <HeatmapWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'heatmap' } as any} + panelId='panel-1' + params={{ + sourceMode: 'watchlist', + marketProvider: 'alpaca', + }} + /> + ) + }) + + expect(mockHeatmapTreemapChart.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + items: [ + expect.objectContaining({ + key: 'default|AAPL||', + sourceLabels: ['Watchlist'], + sizeValue: 2200, + }), + ], + }) + ) + + await act(async () => { + root.render( + <HeatmapWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'heatmap' } as any} + panelId='panel-1' + params={{ + sourceMode: 'portfolio', + marketProvider: 'alpaca', + tradingProvider: 'alpaca', + accountId: 'account-1', + }} + /> + ) + }) + + expect(mockUseTradingPortfolioSnapshot).toHaveBeenLastCalledWith({ + workspaceId: 'workspace-1', + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'account-1', + enabled: true, + }) + expect(mockHeatmapTreemapChart.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + items: [ + expect.objectContaining({ + key: 'default|MSFT||', + sourceLabels: ['Portfolio'], + sizeValue: 100, + }), + ], + }) + ) + expect(mockHeatmapTreemapChart.mock.calls.at(-1)?.[0]).not.toHaveProperty('sourceMode') + }) + + it('uses raw volume for watchlist tile size when selected', async () => { + mockUseWatchlists.mockReturnValue( + createQueryResult({ + data: [ + { + id: 'watchlist-1', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Watchlist', + isSystem: false, + items: [ + { + id: 'watchlist-item', + type: 'listing' as const, + listing: createListing('AAPL'), + }, + ], + settings: { showLogo: true, showTicker: true, showDescription: true }, + createdAt: '', + updatedAt: '', + }, + ], + }) + ) + mockUseMarketQuoteSnapshots.mockReturnValue( + createQueryResult({ + data: { + 'default|AAPL||': { + lastPrice: 110, + previousClose: 100, + change: 10, + changePercent: 10, + volume: 20, + volumeUsd: 2200, + }, + }, + }) + ) + + await act(async () => { + root.render( + <HeatmapWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'heatmap' } as any} + panelId='panel-1' + params={{ + sourceMode: 'watchlist', + watchlistSizeMetric: 'volume', + marketProvider: 'alpaca', + }} + /> + ) + }) + + expect(mockHeatmapTreemapChart.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + items: [ + expect.objectContaining({ + key: 'default|AAPL||', + sizeValue: 20, + }), + ], + }) + ) + }) + + it('writes selected heatmap listings to the linked pair color context', async () => { + mockUseWatchlists.mockReturnValue( + createQueryResult({ + data: [ + { + id: 'watchlist-1', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Watchlist', + isSystem: false, + items: [ + { + id: 'watchlist-item', + type: 'listing' as const, + listing: createListing('AAPL'), + }, + ], + settings: { showLogo: true, showTicker: true, showDescription: true }, + createdAt: '', + updatedAt: '', + }, + ], + }) + ) + + await act(async () => { + root.render( + <HeatmapWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'heatmap' } as any} + panelId='panel-1' + pairColor='blue' + params={{ + sourceMode: 'watchlist', + marketProvider: 'alpaca', + }} + /> + ) + }) + + const onListingSelect = mockHeatmapTreemapChart.mock.calls.at(-1)?.[0].onListingSelect + expect(onListingSelect).toEqual(expect.any(Function)) + + await act(async () => { + onListingSelect(createListing('AAPL')) + }) + + expect(usePairColorStore.getState().contexts.blue.listing).toEqual(createListing('AAPL')) + expect(usePairColorStore.getState().contexts.gray.listing).toBeUndefined() + }) + + it('does not rerender heatmap data when linked pair color context changes elsewhere', async () => { + mockUseWatchlists.mockReturnValue( + createQueryResult({ + data: [ + { + id: 'watchlist-1', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Watchlist', + isSystem: false, + items: [ + { + id: 'watchlist-item', + type: 'listing' as const, + listing: createListing('AAPL'), + }, + ], + settings: { showLogo: true, showTicker: true, showDescription: true }, + createdAt: '', + updatedAt: '', + }, + ], + }) + ) + + await act(async () => { + root.render( + <HeatmapWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'heatmap' } as any} + panelId='panel-1' + pairColor='blue' + params={{ + sourceMode: 'watchlist', + marketProvider: 'alpaca', + }} + /> + ) + }) + + const chartRenderCount = mockHeatmapTreemapChart.mock.calls.length + const quoteRequestCount = mockUseMarketQuoteSnapshots.mock.calls.length + + await act(async () => { + usePairColorStore.getState().setContext('blue', { listing: createListing('MSFT') }) + }) + + expect(mockHeatmapTreemapChart).toHaveBeenCalledTimes(chartRenderCount) + expect(mockUseMarketQuoteSnapshots).toHaveBeenCalledTimes(quoteRequestCount) + }) + + it('shows empty portfolio message when portfolio mode has no listings', async () => { + mockUseTradingAccounts.mockReturnValue( + createQueryResult({ + data: [{ id: 'account-1', name: 'Paper' }], + }) + ) + mockUseTradingPortfolioSnapshot.mockReturnValue(createQueryResult({ positionListings: [] })) + + await act(async () => { + root.render( + <HeatmapWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'heatmap' } as any} + panelId='panel-1' + params={{ + sourceMode: 'portfolio', + marketProvider: 'alpaca', + tradingProvider: 'alpaca', + accountId: 'account-1', + }} + /> + ) + }) + + expect(container.textContent).toContain('No holdings listings found for this account.') + expect(mockHeatmapTreemapChart).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/body.tsx b/apps/tradinggoose/widgets/widgets/heatmap/components/body.tsx new file mode 100644 index 000000000..80d965312 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/body.tsx @@ -0,0 +1,419 @@ +'use client' + +import { useCallback, useEffect, useMemo } from 'react' +import { LoadingAgent } from '@/components/ui/loading-agent' +import { getListingIdentityKey, type ListingIdentity } from '@/lib/listing/identity' +import type { MarketQuoteSnapshot } from '@/lib/market/quote-snapshot-contract' +import { useResolvedListings } from '@/hooks/queries/listing-resolution' +import { useMarketQuoteSnapshots } from '@/hooks/queries/market-quote-snapshots' +import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' +import { useTradingAccounts, useTradingPortfolioSnapshot } from '@/hooks/queries/trading-portfolio' +import { useWatchlists } from '@/hooks/queries/watchlists' +import { useSetPairColorContext } from '@/stores/dashboard/pair-store' +import type { WidgetComponentProps } from '@/widgets/types' +import { + emitHeatmapParamsChange, + useHeatmapParamsPersistence, +} from '@/widgets/utils/heatmap-params' +import { useTradingCredentialServices } from '@/widgets/widgets/components/trading-credential-services' +import { HeatmapTreemapChart } from '@/widgets/widgets/heatmap/components/heatmap-treemap-chart' +import { + getHeatmapTradingProviderAvailabilityIds, + getHeatmapTradingProviderOptions, + resolveHeatmapMarketProviderId, + resolveHeatmapSourceMode, + resolveHeatmapTradingProviderId, + resolveHeatmapWatchlistSizeMetric, +} from '@/widgets/widgets/heatmap/components/shared' +import { + capHeatmapListings, + type HeatmapSourceListing, + resolvePortfolioHeatmapListings, + resolveWatchlistHeatmapListings, +} from '@/widgets/widgets/heatmap/components/source-items' +import type { + HeatmapWatchlistSizeMetric, + HeatmapWidgetParams, +} from '@/widgets/widgets/heatmap/types' + +const HeatmapMessage = ({ message }: { message: string }) => ( + <div className='flex h-full items-center justify-center px-4 text-center text-muted-foreground text-sm'> + {message} + </div> +) + +const isPositiveFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && value > 0 + +const resolvePortfolioSizeValue = (grossQuantity: number | undefined, lastPrice?: number | null) => + isPositiveFiniteNumber(grossQuantity) && isPositiveFiniteNumber(lastPrice) + ? grossQuantity * lastPrice + : undefined + +const resolveWatchlistSizeValue = ( + quote: MarketQuoteSnapshot | null, + metric: HeatmapWatchlistSizeMetric +) => { + const value = metric === 'volume' ? quote?.volume : quote?.volumeUsd + return isPositiveFiniteNumber(value) ? value : undefined +} + +export function HeatmapWidgetBody({ + context, + panelId, + widget, + params, + pairColor = 'gray', + onWidgetParamsChange, +}: WidgetComponentProps) { + const workspaceId = context?.workspaceId ?? null + const widgetKey = widget?.key ?? 'heatmap' + const widgetParams = params && typeof params === 'object' ? (params as HeatmapWidgetParams) : null + const sourceMode = resolveHeatmapSourceMode(widgetParams) + const watchlistSizeMetric = resolveHeatmapWatchlistSizeMetric(widgetParams) + const marketProviderId = resolveHeatmapMarketProviderId(widgetParams) + const refreshAt = + typeof widgetParams?.runtime?.refreshAt === 'number' ? widgetParams.runtime.refreshAt : null + + useHeatmapParamsPersistence({ + onWidgetParamsChange, + panelId, + widget, + params: params && typeof params === 'object' ? (params as Record<string, unknown>) : null, + }) + + useEffect(() => { + const nextParams: Record<string, unknown> = {} + if (!widgetParams?.sourceMode) nextParams.sourceMode = sourceMode + if (Object.keys(nextParams).length === 0) return + emitHeatmapParamsChange({ params: nextParams, panelId, widgetKey }) + }, [panelId, sourceMode, widgetKey, widgetParams]) + + const watchlistsQuery = useWatchlists( + sourceMode === 'watchlist' ? (workspaceId ?? undefined) : undefined + ) + const watchlistSources = useMemo( + () => + watchlistsQuery.isPlaceholderData + ? [] + : resolveWatchlistHeatmapListings(watchlistsQuery.data ?? []), + [watchlistsQuery.data, watchlistsQuery.isPlaceholderData] + ) + + const providerAvailabilityQuery = useOAuthProviderAvailability( + getHeatmapTradingProviderAvailabilityIds(), + sourceMode === 'portfolio' + ) + const tradingProviderOptions = useMemo( + () => getHeatmapTradingProviderOptions(providerAvailabilityQuery.data), + [providerAvailabilityQuery.data] + ) + const tradingProviderId = resolveHeatmapTradingProviderId(widgetParams, tradingProviderOptions) + const hasSelectedTradingProvider = Boolean(tradingProviderId) + const hasInvalidPersistedTradingProvider = + sourceMode === 'portfolio' && + !providerAvailabilityQuery.isLoading && + !providerAvailabilityQuery.error && + Boolean(widgetParams?.tradingProvider) && + !hasSelectedTradingProvider + const isTradingProviderReady = + !providerAvailabilityQuery.isLoading && + !providerAvailabilityQuery.error && + hasSelectedTradingProvider && + tradingProviderOptions.length > 0 + + useEffect(() => { + if (!hasInvalidPersistedTradingProvider) return + emitHeatmapParamsChange({ + params: { + tradingProvider: null, + credentialServiceId: null, + accountId: null, + }, + panelId, + widgetKey, + }) + }, [hasInvalidPersistedTradingProvider, panelId, widgetKey]) + + const credentialServices = useTradingCredentialServices({ + providerId: tradingProviderId, + credentialServiceId: widgetParams?.credentialServiceId, + enabled: sourceMode === 'portfolio' && isTradingProviderReady, + }) + const activeCredentialServiceId = credentialServices.activeServiceId + const accountsQuery = useTradingAccounts({ + workspaceId: workspaceId ?? undefined, + provider: sourceMode === 'portfolio' && isTradingProviderReady ? tradingProviderId : undefined, + credentialServiceId: activeCredentialServiceId, + enabled: sourceMode === 'portfolio' && Boolean(activeCredentialServiceId), + }) + const accounts = accountsQuery.data ?? [] + const singleAccount = accounts.length === 1 ? (accounts[0] ?? null) : null + const activeAccountId = activeCredentialServiceId + ? (widgetParams?.accountId ?? singleAccount?.id) + : undefined + + useEffect(() => { + if (sourceMode !== 'portfolio') return + if (accountsQuery.isLoading) return + if (accountsQuery.error) return + + if (accounts.length === 1) { + const onlyAccount = accounts[0] + if (!onlyAccount) return + if (widgetParams?.accountId) return + emitHeatmapParamsChange({ + params: { + accountId: onlyAccount.id, + credentialServiceId: activeCredentialServiceId, + }, + panelId, + widgetKey, + }) + } + }, [ + accounts, + accountsQuery.error, + accountsQuery.isLoading, + activeCredentialServiceId, + panelId, + sourceMode, + widgetKey, + widgetParams?.accountId, + ]) + + const snapshotQuery = useTradingPortfolioSnapshot({ + workspaceId: workspaceId ?? undefined, + provider: sourceMode === 'portfolio' && isTradingProviderReady ? tradingProviderId : undefined, + credentialServiceId: activeCredentialServiceId, + accountId: activeAccountId, + enabled: sourceMode === 'portfolio', + }) + const portfolioSources = useMemo<HeatmapSourceListing[]>( + () => + resolvePortfolioHeatmapListings( + snapshotQuery.positionListings.map((position) => position.listing) + ), + [snapshotQuery.positionListings] + ) + const portfolioQuantityByKey = useMemo(() => { + const quantityByKey = new Map<string, number>() + + for (const position of snapshotQuery.positionListings) { + quantityByKey.set(getListingIdentityKey(position.listing), position.grossQuantity) + } + + return quantityByKey + }, [snapshotQuery.positionListings]) + const sourceListings = sourceMode === 'portfolio' ? portfolioSources : watchlistSources + const { + visibleItems: cappedSourceListings, + cappedCount, + totalCount, + } = useMemo(() => capHeatmapListings(sourceListings), [sourceListings]) + const listings = useMemo( + () => sourceListings.map((sourceListing) => sourceListing.listing), + [sourceListings] + ) + const cappedListings = useMemo( + () => cappedSourceListings.map((sourceListing) => sourceListing.listing), + [cappedSourceListings] + ) + const quoteItems = useMemo( + () => + cappedSourceListings.map((sourceListing) => ({ + key: sourceListing.key, + listing: sourceListing.listing, + })), + [cappedSourceListings] + ) + const quoteSnapshotsQuery = useMarketQuoteSnapshots({ + workspaceId: workspaceId ?? undefined, + provider: marketProviderId || undefined, + items: quoteItems, + auth: widgetParams?.marketAuth, + providerParams: widgetParams?.marketProviderParams, + refreshKey: refreshAt, + enabled: Boolean(marketProviderId && cappedListings.length > 0), + }) + const resolvedListingsQuery = useResolvedListings({ + listings: cappedListings, + enabled: cappedListings.length > 0, + }) + const setPairContext = useSetPairColorContext() + const handleListingSelect = useCallback( + (listing: ListingIdentity) => { + if (pairColor === 'gray') return + setPairContext(pairColor, { listing }) + }, + [pairColor, setPairContext] + ) + const chartItems = useMemo( + () => + cappedSourceListings.map((sourceListing) => { + const key = sourceListing.key + const quote = quoteSnapshotsQuery.data?.[key] ?? null + const sizeValue = + sourceMode === 'portfolio' + ? resolvePortfolioSizeValue(portfolioQuantityByKey.get(key), quote?.lastPrice) + : resolveWatchlistSizeValue(quote, watchlistSizeMetric) + + return { + ...sourceListing, + key, + resolvedListing: resolvedListingsQuery.data?.[key] ?? null, + quote, + sizeValue, + } + }), + [ + cappedSourceListings, + portfolioQuantityByKey, + quoteSnapshotsQuery.data, + resolvedListingsQuery.data, + sourceMode, + watchlistSizeMetric, + ] + ) + + if (!workspaceId) { + return <HeatmapMessage message='Select a workspace to use the heatmap.' /> + } + + if (sourceMode === 'watchlist') { + if (watchlistsQuery.isLoading || watchlistsQuery.isPlaceholderData) { + return ( + <div className='flex h-full items-center justify-center'> + <LoadingAgent size='md' /> + </div> + ) + } + + if (watchlistsQuery.error) { + return ( + <HeatmapMessage + message={ + watchlistsQuery.error instanceof Error + ? watchlistsQuery.error.message + : 'Failed to load watchlists.' + } + /> + ) + } + } + + if (sourceMode === 'portfolio') { + if (providerAvailabilityQuery.isLoading) { + return ( + <div className='flex h-full items-center justify-center'> + <LoadingAgent size='md' /> + </div> + ) + } + + if (providerAvailabilityQuery.error) { + return ( + <HeatmapMessage + message={ + providerAvailabilityQuery.error instanceof Error + ? providerAvailabilityQuery.error.message + : 'Failed to load trading providers.' + } + /> + ) + } + + if (!tradingProviderId || tradingProviderOptions.length === 0) { + return <HeatmapMessage message='Select a trading provider to load portfolio holdings.' /> + } + + if (!activeAccountId) { + if (credentialServices.isLoading) { + return ( + <div className='flex h-full items-center justify-center'> + <LoadingAgent size='md' /> + </div> + ) + } + + if (!activeCredentialServiceId) { + return <HeatmapMessage message='Select a broker connection to load portfolio holdings.' /> + } + + if (accountsQuery.isLoading && accounts.length === 0) { + return ( + <div className='flex h-full items-center justify-center'> + <LoadingAgent size='md' /> + </div> + ) + } + + if (accountsQuery.error) { + return ( + <HeatmapMessage + message={ + accountsQuery.error instanceof Error + ? accountsQuery.error.message + : 'Failed to load broker accounts.' + } + /> + ) + } + + return <HeatmapMessage message='Select a broker account to load portfolio holdings.' /> + } + + if (snapshotQuery.isLoading && portfolioSources.length === 0) { + return ( + <div className='flex h-full items-center justify-center'> + <LoadingAgent size='md' /> + </div> + ) + } + + if (snapshotQuery.error) { + return ( + <HeatmapMessage + message={ + snapshotQuery.error instanceof Error + ? snapshotQuery.error.message + : 'Failed to load holdings.' + } + /> + ) + } + } + + if (listings.length === 0) { + return ( + <HeatmapMessage + message={ + sourceMode === 'portfolio' + ? 'No holdings listings found for this account.' + : 'No watchlist listings found.' + } + /> + ) + } + + const quoteErrorMessage = quoteSnapshotsQuery.error + ? quoteSnapshotsQuery.error instanceof Error + ? quoteSnapshotsQuery.error.message + : 'Failed to load market quotes.' + : null + + return ( + <div className='flex h-full flex-col gap-2 p-2'> + <div className='min-h-0 flex-1'> + <HeatmapTreemapChart + cappedCount={cappedCount} + errorMessage={quoteErrorMessage} + isLoading={quoteSnapshotsQuery.isLoading && !quoteSnapshotsQuery.data} + items={chartItems} + onListingSelect={pairColor === 'gray' ? undefined : handleListingSelect} + totalCount={totalCount} + /> + </div> + </div> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/header.test.tsx b/apps/tradinggoose/widgets/widgets/heatmap/components/header.test.tsx new file mode 100644 index 000000000..38cc60a41 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/header.test.tsx @@ -0,0 +1,283 @@ +/** + * @vitest-environment jsdom + */ + +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHeatmapHeader } from '@/widgets/widgets/heatmap/components/header' + +const mockUseOAuthProviderAvailability = vi.fn() +const mockEmitHeatmapParamsChange = vi.fn() +type MockTradingAccountSelectorProps = { + onAccountSelect?: (selection: unknown) => void +} +const mockTradingAccountSelector = vi.fn(({ onAccountSelect }: MockTradingAccountSelectorProps) => ( + <button + type='button' + data-testid='trading-account-selector' + onClick={() => + onAccountSelect?.({ + accountId: 'acct-1', + }) + } + > + Trading account + </button> +)) + +vi.mock('@/hooks/queries/oauth-provider-availability', () => ({ + useOAuthProviderAvailability: (...args: unknown[]) => mockUseOAuthProviderAvailability(...args), +})) + +vi.mock('@/widgets/utils/heatmap-params', () => ({ + emitHeatmapParamsChange: (...args: unknown[]) => mockEmitHeatmapParamsChange(...args), +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children?: ReactNode }) => <>{children}</>, + TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>, + TooltipContent: ({ children }: { children?: ReactNode }) => <>{children}</>, +})) + +vi.mock('@/widgets/widgets/components/market-provider-settings-button', () => ({ + MarketProviderSettingsButton: () => <button type='button'>Market settings</button>, +})) + +vi.mock('@/widgets/widgets/components/market-provider-selector', () => ({ + MarketProviderSelector: ({ + value, + onChange, + }: { + value?: string + onChange?: (providerId: string) => void + }) => ( + <button type='button' onClick={() => onChange?.('alpaca')}> + Market provider {value} + </button> + ), +})) + +vi.mock('@/widgets/widgets/components/trading-provider-selector', () => ({ + TradingProviderSelector: ({ + value, + onChange, + }: { + value?: string + onChange?: (providerId: string) => void + }) => ( + <button + type='button' + data-testid='trading-provider-selector' + onClick={() => onChange?.('alpaca')} + > + Trading provider {value} + </button> + ), +})) + +vi.mock('@/widgets/widgets/components/trading-account-selector', () => ({ + TradingAccountSelector: (props: MockTradingAccountSelectorProps) => + mockTradingAccountSelector(props), +})) + +vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ + widgetHeaderButtonGroupClassName: (className?: string) => + ['controls', className].filter(Boolean).join(' '), + widgetHeaderIconButtonClassName: () => 'icon-button', +})) + +const createQueryResult = <T,>(overrides: Partial<T> = {}) => + ({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + ...overrides, + }) as T + +describe('HeatmapHeaderControls', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + mockUseOAuthProviderAvailability.mockReturnValue( + createQueryResult({ + data: { + 'alpaca-live': true, + 'alpaca-paper': true, + }, + }) + ) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('does not normalize an invalid market provider to a fallback provider', async () => { + const slots = renderHeatmapHeader?.({ + panelId: 'panel-1', + widget: { + key: 'heatmap', + params: { + sourceMode: 'watchlist', + marketProvider: 'unsupported-provider', + }, + } as any, + }) + + await act(async () => { + root.render( + <> + {slots?.left} + {slots?.center} + {slots?.right} + </> + ) + }) + + expect(mockEmitHeatmapParamsChange).not.toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + marketProvider: expect.any(String), + }), + }) + ) + expect(mockUseOAuthProviderAvailability).not.toHaveBeenCalled() + expect(mockTradingAccountSelector).not.toHaveBeenCalled() + }) + + it('shows the account selector after a portfolio trading provider is selected', async () => { + const slots = renderHeatmapHeader?.({ + panelId: 'panel-1', + widget: { + key: 'heatmap', + params: { + sourceMode: 'portfolio', + tradingProvider: 'alpaca', + }, + } as any, + }) + + await act(async () => { + root.render( + <> + {slots?.left} + {slots?.center} + {slots?.right} + </> + ) + }) + + expect(container.textContent).toContain('Trading account') + expect(mockTradingAccountSelector).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'alpaca', + accountId: undefined, + }) + ) + }) + + it('switches source mode from the header button group', async () => { + const slots = renderHeatmapHeader?.({ + panelId: 'panel-1', + widget: { + key: 'heatmap', + params: { + sourceMode: 'watchlist', + }, + } as any, + }) + + await act(async () => { + root.render(<>{slots?.center}</>) + }) + + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Portfolio') + ?.click() + }) + + expect(mockEmitHeatmapParamsChange).toHaveBeenCalledWith({ + params: { sourceMode: 'portfolio' }, + panelId: 'panel-1', + widgetKey: 'heatmap', + }) + }) + + it('switches watchlist tile size metric from the header button group', async () => { + const slots = renderHeatmapHeader?.({ + panelId: 'panel-1', + widget: { + key: 'heatmap', + params: { + sourceMode: 'watchlist', + watchlistSizeMetric: 'volumeUsd', + }, + } as any, + }) + + await act(async () => { + root.render(<>{slots?.center}</>) + }) + + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Volume') + ?.click() + }) + + expect(mockEmitHeatmapParamsChange).toHaveBeenCalledWith({ + params: { watchlistSizeMetric: 'volume' }, + panelId: 'panel-1', + widgetKey: 'heatmap', + }) + }) + + it('updates the account id from account selection', async () => { + const slots = renderHeatmapHeader?.({ + panelId: 'panel-1', + widget: { + key: 'heatmap', + params: { + sourceMode: 'portfolio', + tradingProvider: 'alpaca', + }, + } as any, + }) + + await act(async () => { + root.render(<>{slots?.right}</>) + }) + + await act(async () => { + container + .querySelector<HTMLButtonElement>('[data-testid="trading-account-selector"]') + ?.click() + }) + + expect(mockEmitHeatmapParamsChange).toHaveBeenCalledWith({ + params: { + accountId: 'acct-1', + }, + panelId: 'panel-1', + widgetKey: 'heatmap', + }) + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/header.tsx b/apps/tradinggoose/widgets/widgets/heatmap/components/header.tsx new file mode 100644 index 000000000..768fdaad2 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/header.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useMemo } from 'react' +import { Button } from '@/components/ui/button' +import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' +import type { DashboardWidgetDefinition } from '@/widgets/types' +import { emitHeatmapParamsChange } from '@/widgets/utils/heatmap-params' +import { MarketProviderControls } from '@/widgets/widgets/components/market-provider-controls' +import { TradingProviderControls } from '@/widgets/widgets/components/trading-provider-controls' +import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' +import { WidgetHeaderRefreshButton } from '@/widgets/widgets/components/widget-header-refresh-button' +import { + getHeatmapMarketProviderOptions, + getHeatmapTradingProviderAvailabilityIds, + getHeatmapTradingProviderOptions, + HEATMAP_SOURCE_MODES, + HEATMAP_WATCHLIST_SIZE_METRICS, + resolveHeatmapMarketProviderId, + resolveHeatmapSourceMode, + resolveHeatmapTradingProviderId, + resolveHeatmapWatchlistSizeMetric, +} from '@/widgets/widgets/heatmap/components/shared' +import type { HeatmapWidgetParams } from '@/widgets/widgets/heatmap/types' + +type HeaderControlProps = { + workspaceId?: string + panelId?: string + widgetKey: string + params: HeatmapWidgetParams | null +} + +function HeatmapMarketControls({ workspaceId, panelId, widgetKey, params }: HeaderControlProps) { + const marketProviderOptions = useMemo(() => getHeatmapMarketProviderOptions(), []) + const marketProviderId = resolveHeatmapMarketProviderId(params, marketProviderOptions) + + return ( + <MarketProviderControls + value={marketProviderId} + options={marketProviderOptions} + providerParams={params?.marketProviderParams} + authParams={params?.marketAuth} + workspaceId={workspaceId} + onChange={(nextProvider) => { + if (!nextProvider || nextProvider === marketProviderId) return + emitHeatmapParamsChange({ + params: { + marketProvider: nextProvider, + marketProviderParams: null, + marketAuth: null, + runtime: { refreshAt: Date.now() }, + }, + panelId, + widgetKey, + }) + }} + onSettingsSave={({ providerParams, auth }) => { + emitHeatmapParamsChange({ + params: { + marketProviderParams: providerParams, + marketAuth: auth, + runtime: { refreshAt: Date.now() }, + }, + panelId, + widgetKey, + }) + }} + /> + ) +} + +function HeatmapSourceControls({ panelId, widgetKey, params }: HeaderControlProps) { + const sourceMode = resolveHeatmapSourceMode(params) + + return ( + <div className='flex h-7 items-center gap-1 rounded-sm border border-border/70 bg-card/60 p-1'> + {HEATMAP_SOURCE_MODES.map((mode) => { + const isSelected = mode.id === sourceMode + + return ( + <Button + key={mode.id} + type='button' + variant={isSelected ? 'default' : 'ghost'} + size='sm' + className='h-5 min-w-14 rounded-xs px-3 text-sm' + onClick={() => { + if (mode.id === sourceMode) return + emitHeatmapParamsChange({ + params: { sourceMode: mode.id }, + panelId, + widgetKey, + }) + }} + > + {mode.label} + </Button> + ) + })} + </div> + ) +} + +function HeatmapWatchlistSizeControls({ panelId, widgetKey, params }: HeaderControlProps) { + const sizeMetric = resolveHeatmapWatchlistSizeMetric(params) + + return ( + <div className='flex h-7 items-center gap-1 rounded-sm border border-border/70 bg-card/60 p-1'> + {HEATMAP_WATCHLIST_SIZE_METRICS.map((metric) => { + const isSelected = metric.id === sizeMetric + + return ( + <Button + key={metric.id} + type='button' + variant={isSelected ? 'default' : 'ghost'} + size='sm' + className='h-5 min-w-16 rounded-xs px-3 text-sm' + onClick={() => { + if (metric.id === sizeMetric) return + emitHeatmapParamsChange({ + params: { watchlistSizeMetric: metric.id }, + panelId, + widgetKey, + }) + }} + > + {metric.label} + </Button> + ) + })} + </div> + ) +} + +function HeatmapPortfolioControls({ workspaceId, panelId, widgetKey, params }: HeaderControlProps) { + const providerAvailabilityQuery = useOAuthProviderAvailability( + getHeatmapTradingProviderAvailabilityIds() + ) + const providerOptions = useMemo( + () => getHeatmapTradingProviderOptions(providerAvailabilityQuery.data), + [providerAvailabilityQuery.data] + ) + const providerId = resolveHeatmapTradingProviderId(params, providerOptions) + + return ( + <TradingProviderControls + workspaceId={workspaceId} + providerId={providerId} + providerOptions={providerOptions} + credentialServiceId={params?.credentialServiceId} + accountId={params?.accountId} + toolName='Heatmap' + onProviderChange={(nextProvider) => { + if (!nextProvider || nextProvider === providerId) return + emitHeatmapParamsChange({ + params: { + tradingProvider: nextProvider, + credentialServiceId: null, + accountId: null, + }, + panelId, + widgetKey, + }) + }} + onAccountSelect={({ accountId, credentialServiceId }) => { + emitHeatmapParamsChange({ + params: { + accountId, + ...(credentialServiceId ? { credentialServiceId } : {}), + }, + panelId, + widgetKey, + }) + }} + /> + ) +} + +function HeatmapRefreshControl({ panelId, widgetKey }: HeaderControlProps) { + return ( + <WidgetHeaderRefreshButton + label='Refresh heatmap' + onClick={() => { + emitHeatmapParamsChange({ + params: { runtime: { refreshAt: Date.now() } }, + panelId, + widgetKey, + }) + }} + /> + ) +} + +export const renderHeatmapHeader: DashboardWidgetDefinition['renderHeader'] = ({ + panelId, + widget, + context, +}) => { + const widgetKey = widget?.key ?? 'heatmap' + const params = (widget?.params as HeatmapWidgetParams | null | undefined) ?? null + const sourceMode = resolveHeatmapSourceMode(params) + + return { + left: ( + <HeatmapMarketControls + workspaceId={context?.workspaceId} + panelId={panelId} + widgetKey={widgetKey} + params={params} + /> + ), + center: ( + <div className='flex min-w-0 items-center gap-1'> + <HeatmapSourceControls panelId={panelId} widgetKey={widgetKey} params={params} /> + {sourceMode === 'watchlist' ? ( + <HeatmapWatchlistSizeControls panelId={panelId} widgetKey={widgetKey} params={params} /> + ) : null} + </div> + ), + right: ( + <div className={widgetHeaderButtonGroupClassName('min-w-0')}> + {sourceMode === 'portfolio' ? ( + <HeatmapPortfolioControls + workspaceId={context?.workspaceId} + panelId={panelId} + widgetKey={widgetKey} + params={params} + /> + ) : null} + <HeatmapRefreshControl panelId={panelId} widgetKey={widgetKey} params={params} /> + </div> + ), + } +} diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx b/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx new file mode 100644 index 000000000..60ec2f4a1 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx @@ -0,0 +1,502 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { HeatmapTreemapChart } from '@/widgets/widgets/heatmap/components/heatmap-treemap-chart' + +describe('HeatmapTreemapChart', () => { + let container: HTMLDivElement + let root: Root + let originalResizeObserver: typeof globalThis.ResizeObserver | undefined + + beforeEach(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + originalResizeObserver = globalThis.ResizeObserver + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + globalThis.ResizeObserver = originalResizeObserver as typeof globalThis.ResizeObserver + }) + + it('renders without ResizeObserver by using the initial measurement fallback', async () => { + globalThis.ResizeObserver = undefined as unknown as typeof globalThis.ResizeObserver + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 120, + height: 120, + left: 0, + right: 240, + top: 0, + width: 240, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }) + + await act(async () => { + root.render( + <TooltipProvider> + <HeatmapTreemapChart + items={[ + { + key: 'default|AAPL||', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + resolvedListing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + base: 'AAPL', + name: 'Apple Inc.', + iconUrl: 'https://example.com/aapl.svg', + countryCode: 'US', + }, + quote: { + lastPrice: 110, + previousClose: 100, + change: 10, + changePercent: 10, + }, + sourceLabels: ['Watchlist'], + }, + ]} + /> + </TooltipProvider> + ) + }) + + expect(container.textContent).toContain('AAPL') + expect(container.textContent).not.toContain('Apple Inc.') + expect(container.querySelector('img[alt="US flag"]')?.getAttribute('src')).toBe( + 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/1f1fa-1f1f8.svg' + ) + expect(container.querySelector('img')?.getAttribute('src')).toBe('https://example.com/aapl.svg') + const button = container.querySelector('button') + expect(button?.getAttribute('aria-label')).toContain('Previous 100.0') + expect(button?.getAttribute('aria-label')).toContain('Change +10.00') + expect(button?.getAttribute('aria-label')).toContain('+10.00%') + }) + + it('keeps a default tile layout when the first browser measurement is zero', async () => { + globalThis.ResizeObserver = undefined as unknown as typeof globalThis.ResizeObserver + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }) + + await act(async () => { + root.render( + <TooltipProvider> + <HeatmapTreemapChart + items={[ + { + key: 'default|AAPL||', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + quote: { + lastPrice: 110, + previousClose: 100, + change: 10, + changePercent: 10, + }, + }, + ]} + /> + </TooltipProvider> + ) + }) + + expect(container.querySelector('button')?.textContent).toContain('AAPL') + }) + + it('renders split blocks without user resize handles', async () => { + globalThis.ResizeObserver = undefined as unknown as typeof globalThis.ResizeObserver + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 120, + height: 120, + left: 0, + right: 240, + top: 0, + width: 240, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }) + + await act(async () => { + root.render( + <TooltipProvider> + <HeatmapTreemapChart + items={[ + { + key: 'default|AAPL||', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }, + { + key: 'default|MSFT||', + listing: { + listing_id: 'MSFT', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }, + ]} + /> + </TooltipProvider> + ) + }) + + expect(container.querySelectorAll('button')).toHaveLength(2) + expect(container.textContent).toContain('AAPL') + expect(container.textContent).toContain('MSFT') + expect(container.querySelector('[role="separator"]')).toBeNull() + }) + + it('emits the canonical listing when a tile is clicked', async () => { + globalThis.ResizeObserver = undefined as unknown as typeof globalThis.ResizeObserver + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 120, + height: 120, + left: 0, + right: 240, + top: 0, + width: 240, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }) + + const onListingSelect = vi.fn() + const listing = { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default' as const, + } + + await act(async () => { + root.render( + <TooltipProvider> + <HeatmapTreemapChart + items={[{ key: 'default|AAPL||', listing }]} + onListingSelect={onListingSelect} + /> + </TooltipProvider> + ) + }) + + await act(async () => { + container.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onListingSelect).toHaveBeenCalledWith(listing) + }) + + it('uses ResizeObserver dimensions for tile visibility', async () => { + globalThis.ResizeObserver = class { + private callback: ResizeObserverCallback + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + + observe(target: Element) { + this.callback( + [ + { + target, + contentRect: { + bottom: 40, + height: 40, + left: 0, + right: 40, + top: 0, + width: 40, + x: 0, + y: 0, + toJSON: () => ({}), + }, + } as ResizeObserverEntry, + ], + this as ResizeObserver + ) + } + + disconnect() {} + unobserve() {} + } as unknown as typeof globalThis.ResizeObserver + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }) + + await act(async () => { + root.render( + <TooltipProvider> + <HeatmapTreemapChart + items={[ + { + key: 'default|AAPL||', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + resolvedListing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + base: 'AAPL', + name: 'Apple Inc.', + iconUrl: 'https://example.com/aapl.svg', + }, + quote: { + lastPrice: 110, + previousClose: 100, + change: 10, + changePercent: 10, + }, + }, + ]} + /> + </TooltipProvider> + ) + }) + + const button = container.querySelector('button') + const icon = container.querySelector('img') + expect(button?.textContent?.trim()).toBe('') + expect(icon?.style.width).toBe('16px') + expect(icon?.style.height).toBe('16px') + }) + + it('formats crypto pairs with base and quote without the full listing name', async () => { + globalThis.ResizeObserver = undefined as unknown as typeof globalThis.ResizeObserver + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 120, + height: 120, + left: 0, + right: 240, + top: 0, + width: 240, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }) + + await act(async () => { + root.render( + <TooltipProvider> + <HeatmapTreemapChart + items={[ + { + key: 'crypto||BTC|USD', + listing: { + listing_id: '', + base_id: 'BTC', + quote_id: 'USD', + listing_type: 'crypto', + }, + resolvedListing: { + listing_id: '', + base_id: 'BTC', + quote_id: 'USD', + listing_type: 'crypto', + base: 'BTC', + quote: 'USD', + name: 'Bitcoin', + }, + quote: { + lastPrice: 110, + previousClose: 100, + change: 10, + changePercent: 10, + }, + }, + ]} + /> + </TooltipProvider> + ) + }) + + expect(container.querySelector('button')?.textContent).toContain('BTC/USD') + expect(container.querySelector('button')?.textContent).not.toContain('Bitcoin') + }) + + it('shows a scaled listing icon for small non-mini tiles', async () => { + globalThis.ResizeObserver = undefined as unknown as typeof globalThis.ResizeObserver + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 40, + height: 40, + left: 0, + right: 40, + top: 0, + width: 40, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }) + + await act(async () => { + root.render( + <TooltipProvider> + <HeatmapTreemapChart + items={[ + { + key: 'default|AAPL||', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + resolvedListing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + base: 'AAPL', + name: 'Apple Inc.', + iconUrl: 'https://example.com/aapl.svg', + }, + quote: { + lastPrice: 110, + previousClose: 100, + change: 10, + changePercent: 10, + }, + }, + ]} + /> + </TooltipProvider> + ) + }) + + const icon = container.querySelector('img') + expect(icon?.getAttribute('src')).toBe('https://example.com/aapl.svg') + expect(icon?.style.width).toBe('16px') + expect(icon?.style.height).toBe('16px') + expect(container.querySelector('button')?.textContent?.trim()).toBe('') + }) + + it('hides visible tile text when the tile is below the label threshold', async () => { + globalThis.ResizeObserver = undefined as unknown as typeof globalThis.ResizeObserver + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 20, + height: 20, + left: 0, + right: 40, + top: 0, + width: 40, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }) + + await act(async () => { + root.render( + <TooltipProvider> + <HeatmapTreemapChart + items={[ + { + key: 'default|AAPL||', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + resolvedListing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + base: 'AAPL', + name: 'Apple Inc.', + iconUrl: 'https://example.com/aapl.svg', + }, + quote: { + lastPrice: 110, + previousClose: 100, + change: 10, + changePercent: 10, + }, + sourceLabels: ['Watchlist'], + }, + ]} + /> + </TooltipProvider> + ) + }) + + expect(container.querySelector('button')?.textContent?.trim()).toBe('') + expect(container.querySelector('img')).toBeNull() + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.tsx b/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.tsx new file mode 100644 index 000000000..40526af02 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.tsx @@ -0,0 +1,348 @@ +'use client' + +import { useLayoutEffect, useMemo, useRef, useState } from 'react' +import { LoadingAgent } from '@/components/ui/loading-agent' +import { ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { buildListingDisplay, getFlagData } from '@/widgets/widgets/data_chart/utils/listing-utils' +import { resolveHeatmapTileColor } from '@/widgets/widgets/heatmap/utils/color' +import { + formatHeatmapChange, + formatHeatmapPercent, + formatHeatmapPrice, +} from '@/widgets/widgets/heatmap/utils/format' +import { + buildHeatmapTreemapLayout, + type HeatmapTreemapInputItem, + type HeatmapTreemapLeafNode, + type HeatmapTreemapNode, + type HeatmapTreemapTile, +} from '@/widgets/widgets/heatmap/utils/treemap-layout' + +type HeatmapTreemapChartProps = { + items: HeatmapTreemapInputItem[] + isLoading?: boolean + errorMessage?: string | null + cappedCount?: number + totalCount?: number + onListingSelect?: (listing: HeatmapTreemapInputItem['listing']) => void +} + +const resolveQuoteChange = (quote: HeatmapTreemapInputItem['quote']) => { + if (typeof quote?.change === 'number' && Number.isFinite(quote.change)) { + return quote.change + } + if ( + typeof quote?.lastPrice === 'number' && + Number.isFinite(quote.lastPrice) && + typeof quote.previousClose === 'number' && + Number.isFinite(quote.previousClose) + ) { + return quote.lastPrice - quote.previousClose + } + return undefined +} + +const resolveTileIconSize = (width: number, height: number) => { + const minDimension = Math.min(width, height) + if (minDimension < 24) return 0 + return Math.max(14, Math.min(64, Math.floor(minDimension * 0.42))) +} + +const resolveTileDisplay = (tile: HeatmapTreemapTile) => { + if (!tile.resolvedListing) { + return { + symbolParts: { base: tile.label, quote: '' }, + symbolText: tile.label, + flagData: null, + flagCountryCode: '', + } + } + + const { listingSymbolParts, listingSymbolText } = buildListingDisplay(tile.resolvedListing) + const flagData = + tile.resolvedListing.listing_type === 'default' + ? getFlagData(tile.resolvedListing.countryCode) + : null + return { + symbolParts: listingSymbolParts, + symbolText: listingSymbolText, + flagData, + flagCountryCode: tile.resolvedListing.countryCode?.trim().toUpperCase() ?? '', + } +} + +const HeatmapTreemapMessage = ({ message }: { message: string }) => ( + <div className='flex h-full items-center justify-center px-4 text-center text-muted-foreground text-sm'> + {message} + </div> +) + +type ElementSize = { + width: number + height: number +} + +const DEFAULT_CHART_SIZE: ElementSize = { width: 320, height: 180 } +const EMPTY_SIZE: ElementSize = { width: 0, height: 0 } + +const readElementSize = (element: HTMLElement | null) => { + if (!element) return { width: 0, height: 0 } + const rect = element.getBoundingClientRect() + return { + width: Math.max(0, Math.floor(rect.width), element.clientWidth), + height: Math.max(0, Math.floor(rect.height), element.clientHeight), + } +} + +const readEntrySize = (entry?: ResizeObserverEntry) => { + if (!entry) return { width: 0, height: 0 } + return { + width: Math.max(0, Math.floor(entry.contentRect.width)), + height: Math.max(0, Math.floor(entry.contentRect.height)), + } +} + +const useObservedElementSize = <ElementType extends HTMLElement>( + fallbackSize: ElementSize, + options: { observeParent?: boolean } = {} +) => { + const elementRef = useRef<ElementType | null>(null) + const [size, setSize] = useState(fallbackSize) + const observeParent = options.observeParent ?? false + + useLayoutEffect(() => { + const element = elementRef.current + if (!element) return + + const updateSize = (entry?: ResizeObserverEntry) => { + const entrySize = readEntrySize(entry) + const elementSize = readElementSize(element) + const parentSize = observeParent ? readElementSize(element.parentElement) : EMPTY_SIZE + setSize((currentSize) => { + const measuredWidth = Math.max(entrySize.width, elementSize.width, parentSize.width) + const measuredHeight = Math.max(entrySize.height, elementSize.height, parentSize.height) + const nextSize = { + width: measuredWidth || fallbackSize.width || currentSize.width, + height: measuredHeight || fallbackSize.height || currentSize.height, + } + + return currentSize.width === nextSize.width && currentSize.height === nextSize.height + ? currentSize + : nextSize + }) + } + + updateSize() + const frame = window.requestAnimationFrame(() => updateSize()) + if (typeof ResizeObserver === 'undefined') { + return () => window.cancelAnimationFrame(frame) + } + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + updateSize(entry) + } + }) + resizeObserver.observe(element) + if (observeParent && element.parentElement) { + resizeObserver.observe(element.parentElement) + } + return () => { + window.cancelAnimationFrame(frame) + resizeObserver.disconnect() + } + }, [fallbackSize.height, fallbackSize.width, observeParent]) + + return [elementRef, size] as const +} + +const HeatmapTileButton = ({ + node, + onListingSelect, +}: { + node: HeatmapTreemapLeafNode + onListingSelect?: (listing: HeatmapTreemapInputItem['listing']) => void +}) => { + const tile = node.tile + const tileWidth = node.width + const tileHeight = node.height + const color = resolveHeatmapTileColor(tile.quote?.changePercent) + const showSymbol = tileWidth >= 44 && tileHeight >= 28 + const showPercent = tileWidth >= 76 && tileHeight >= 48 + const showPrice = tileWidth >= 120 && tileHeight >= 72 + const iconUrl = tile.resolvedListing?.iconUrl?.trim() + const iconSize = iconUrl ? resolveTileIconSize(tileWidth, tileHeight) : 0 + const { symbolParts, symbolText, flagData, flagCountryCode } = resolveTileDisplay(tile) + const flagImageUrl = flagData + ? `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${flagData.codepoints}.svg` + : null + const sourceText = tile.sourceLabels?.length ? tile.sourceLabels.join(', ') : 'Source' + const lastPriceText = formatHeatmapPrice(tile.quote?.lastPrice) + const previousCloseText = formatHeatmapPrice(tile.quote?.previousClose) + const changeText = formatHeatmapChange(resolveQuoteChange(tile.quote)) + const percentText = formatHeatmapPercent(tile.quote?.changePercent) + const quoteText = tile.quote?.error + ? tile.quote.error + : `Last ${lastPriceText} · Previous ${previousCloseText} · Change ${changeText} · ${percentText}` + + return ( + <Tooltip> + <TooltipTrigger asChild> + <button + type='button' + className={cn( + 'relative h-full w-full overflow-hidden rounded-sm border p-2 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring', + color.className + )} + onClick={onListingSelect ? () => onListingSelect(tile.listing) : undefined} + aria-label={`${tile.name}: ${quoteText}; ${sourceText}`} + > + {iconUrl && iconSize > 0 ? ( + <img + src={iconUrl} + alt='' + aria-hidden='true' + className='-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-1/2 z-0 rounded-md border border-border/70 object-contain drop-shadow-lg' + style={{ + width: iconSize, + height: iconSize, + }} + /> + ) : null} + {showSymbol ? ( + <div className='relative z-10 flex h-full min-h-0 flex-col justify-between gap-1'> + <div className='min-w-0'> + <div className='flex min-w-0 items-center gap-1'> + <div className='min-w-0 truncate font-semibold text-[12px] leading-4'> + <span>{symbolParts.base || symbolText}</span> + {symbolParts.quote ? ( + <span className='font-medium opacity-75'>/{symbolParts.quote}</span> + ) : null} + </div> + {flagImageUrl ? ( + <img + src={flagImageUrl} + alt={`${flagCountryCode} flag`} + className='h-3.5 w-3.5 shrink-0' + loading='lazy' + /> + ) : null} + </div> + </div> + {showPercent ? ( + <div className='min-w-0'> + <div className='truncate font-medium text-[12px] leading-4'>{percentText}</div> + {showPrice ? ( + <div className='truncate text-[10px] leading-3 opacity-75'>{lastPriceText}</div> + ) : null} + </div> + ) : null} + </div> + ) : null} + </button> + </TooltipTrigger> + <TooltipContent side='top' className='max-w-[260px] whitespace-normal'> + <div className='space-y-1'> + <div className='font-medium'>{tile.name}</div> + {tile.quote?.error ? ( + <div>{tile.quote.error}</div> + ) : ( + <> + <div>Last {lastPriceText}</div> + <div>Previous close {previousCloseText}</div> + <div> + Change {changeText} ({percentText}) + </div> + </> + )} + <div className='text-white/75 dark:text-black/70'>{sourceText}</div> + </div> + </TooltipContent> + </Tooltip> + ) +} + +const HeatmapTreemapPanelNode = ({ + node, + onListingSelect, +}: { + node: HeatmapTreemapNode + onListingSelect?: (listing: HeatmapTreemapInputItem['listing']) => void +}) => { + if (node.type === 'leaf') { + return <HeatmapTileButton node={node} onListingSelect={onListingSelect} /> + } + + return ( + <ResizablePanelGroup + key={node.key} + direction={node.direction} + className='h-full min-h-0 w-full min-w-0 gap-0.5 overflow-hidden' + > + {node.children.map((child, index) => ( + <ResizablePanel + key={`${node.key}-${child.key}`} + id={`${node.key}-${child.key}`} + order={index + 1} + defaultSize={node.defaultSizes[index]} + minSize={0} + className='min-h-0 min-w-0 overflow-hidden' + > + <HeatmapTreemapPanelNode node={child} onListingSelect={onListingSelect} /> + </ResizablePanel> + ))} + </ResizablePanelGroup> + ) +} + +export function HeatmapTreemapChart({ + items, + isLoading = false, + errorMessage = null, + cappedCount = 0, + totalCount, + onListingSelect, +}: HeatmapTreemapChartProps) { + const [containerRef, size] = useObservedElementSize<HTMLDivElement>(DEFAULT_CHART_SIZE, { + observeParent: true, + }) + + const layout = useMemo( + () => + buildHeatmapTreemapLayout({ + items, + width: size.width, + height: size.height, + }), + [items, size.height, size.width] + ) + + if (isLoading) { + return ( + <div className='flex h-full items-center justify-center'> + <LoadingAgent size='md' /> + </div> + ) + } + + if (errorMessage) { + return <HeatmapTreemapMessage message={errorMessage} /> + } + + const resolvedTotalCount = totalCount ?? items.length + cappedCount + + return ( + <div ref={containerRef} className='relative h-full w-full overflow-hidden'> + {cappedCount > 0 ? ( + <div className='absolute top-1 right-1 z-10 rounded-sm border border-border/70 bg-card/90 px-2 py-1 text-muted-foreground text-xs shadow-sm'> + Showing first {items.length} of {resolvedTotalCount} listings. + </div> + ) : null} + {layout ? ( + <HeatmapTreemapPanelNode key={layout.key} node={layout} onListingSelect={onListingSelect} /> + ) : null} + </div> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/shared.test.ts b/apps/tradinggoose/widgets/widgets/heatmap/components/shared.test.ts new file mode 100644 index 000000000..bfb4e812c --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/shared.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + resolveHeatmapMarketProviderId, + resolveHeatmapSourceMode, + resolveHeatmapTradingProviderId, + resolveHeatmapWatchlistSizeMetric, +} from '@/widgets/widgets/heatmap/components/shared' + +describe('heatmap shared helpers', () => { + const providerOptions = [ + { id: 'alpaca', name: 'Alpaca' }, + { id: 'tradier', name: 'Tradier' }, + ] + + it('does not infer a trading provider fallback for portfolio mode', () => { + expect(resolveHeatmapTradingProviderId(null, providerOptions)).toBe('') + expect(resolveHeatmapTradingProviderId({}, providerOptions)).toBe('') + expect(resolveHeatmapTradingProviderId({ tradingProvider: 'missing' }, providerOptions)).toBe( + '' + ) + expect(resolveHeatmapTradingProviderId({ tradingProvider: 'tradier' }, providerOptions)).toBe( + 'tradier' + ) + }) + + it('does not infer a market provider fallback', () => { + expect(resolveHeatmapMarketProviderId(null, providerOptions)).toBe('') + expect(resolveHeatmapMarketProviderId({}, providerOptions)).toBe('') + expect(resolveHeatmapMarketProviderId({ marketProvider: 'missing' }, providerOptions)).toBe('') + expect(resolveHeatmapMarketProviderId({ marketProvider: 'alpaca' }, providerOptions)).toBe( + 'alpaca' + ) + }) + + it('defaults source mode to watchlist unless portfolio is explicitly persisted', () => { + expect(resolveHeatmapSourceMode(null)).toBe('watchlist') + expect(resolveHeatmapSourceMode({ sourceMode: 'watchlist' })).toBe('watchlist') + expect(resolveHeatmapSourceMode({ sourceMode: 'portfolio' })).toBe('portfolio') + }) + + it('defaults watchlist tile sizing to volume USD', () => { + expect(resolveHeatmapWatchlistSizeMetric(null)).toBe('volumeUsd') + expect(resolveHeatmapWatchlistSizeMetric({ watchlistSizeMetric: 'volume' })).toBe('volume') + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/shared.ts b/apps/tradinggoose/widgets/widgets/heatmap/components/shared.ts new file mode 100644 index 000000000..6481ea3fc --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/shared.ts @@ -0,0 +1,59 @@ +import { + getTradingWidgetProviderAvailabilityIds, + getTradingWidgetProviderOptions, +} from '@/widgets/utils/trading-widget-providers' +import { + resolveConfiguredSeriesMarketProviderId, + getSeriesMarketProviderOptions, +} from '@/widgets/widgets/data_chart/options' +import type { + HeatmapSourceMode, + HeatmapWatchlistSizeMetric, + HeatmapWidgetParams, +} from '@/widgets/widgets/heatmap/types' + +export const HEATMAP_SOURCE_MODES: Array<{ id: HeatmapSourceMode; label: string }> = [ + { id: 'watchlist', label: 'Watchlist' }, + { id: 'portfolio', label: 'Portfolio' }, +] + +export const HEATMAP_WATCHLIST_SIZE_METRICS: Array<{ + id: HeatmapWatchlistSizeMetric + label: string +}> = [ + { id: 'volumeUsd', label: 'Volume USD' }, + { id: 'volume', label: 'Volume' }, +] + +const DEFAULT_HEATMAP_TRADING_PROVIDER_OPTIONS = getTradingWidgetProviderOptions('holdings') + +export const getHeatmapMarketProviderOptions = () => getSeriesMarketProviderOptions() + +export const resolveHeatmapMarketProviderId = ( + params: HeatmapWidgetParams | null | undefined, + options = getHeatmapMarketProviderOptions() +) => resolveConfiguredSeriesMarketProviderId(params?.marketProvider, options) + +export const resolveHeatmapSourceMode = ( + params: HeatmapWidgetParams | null | undefined +): HeatmapSourceMode => (params?.sourceMode === 'portfolio' ? 'portfolio' : 'watchlist') + +export const resolveHeatmapWatchlistSizeMetric = ( + params: HeatmapWidgetParams | null | undefined +): HeatmapWatchlistSizeMetric => (params?.watchlistSizeMetric === 'volume' ? 'volume' : 'volumeUsd') + +export const getHeatmapTradingProviderAvailabilityIds = () => + getTradingWidgetProviderAvailabilityIds('holdings') + +export const getHeatmapTradingProviderOptions = (providerAvailability?: Record<string, boolean>) => + getTradingWidgetProviderOptions('holdings', providerAvailability) + +export const resolveHeatmapTradingProviderId = ( + params: HeatmapWidgetParams | null | undefined, + providerOptions: Array<{ id: string; name: string }> = DEFAULT_HEATMAP_TRADING_PROVIDER_OPTIONS +) => { + const providerId = + typeof params?.tradingProvider === 'string' ? params.tradingProvider.trim() : '' + if (!providerId) return '' + return providerOptions.some((option) => option.id === providerId) ? providerId : '' +} diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/source-items.test.ts b/apps/tradinggoose/widgets/widgets/heatmap/components/source-items.test.ts new file mode 100644 index 000000000..d8ceb0e05 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/source-items.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { + capHeatmapListings, + HEATMAP_LISTING_CAP, + resolvePortfolioHeatmapListings, + resolveWatchlistHeatmapListings, +} from '@/widgets/widgets/heatmap/components/source-items' + +const createListing = (symbol: string) => ({ + listing_id: symbol, + base_id: '', + quote_id: '', + listing_type: 'default' as const, +}) + +describe('heatmap source item helpers', () => { + it('dedupes watchlist listings across workspace-user watchlists and tracks source labels', () => { + const listing = createListing('AAPL') + + expect( + resolveWatchlistHeatmapListings([ + { + id: 'list-1', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'One', + isSystem: false, + items: [ + { id: 'a', type: 'listing', listing }, + { id: 'section', type: 'section', label: 'Tech' }, + ], + settings: { showLogo: true, showTicker: true, showDescription: true }, + createdAt: '', + updatedAt: '', + }, + { + id: 'list-2', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Two', + isSystem: false, + items: [{ id: 'b', type: 'listing', listing }], + settings: { showLogo: true, showTicker: true, showDescription: true }, + createdAt: '', + updatedAt: '', + }, + ]) + ).toEqual([ + { + key: 'default|AAPL||', + listing, + sourceLabels: ['One', 'Two'], + }, + ]) + }) + + it('dedupes portfolio listings in input order and labels them as portfolio sourced', () => { + const aapl = createListing('AAPL') + const msft = createListing('MSFT') + + expect(resolvePortfolioHeatmapListings([aapl, msft, aapl])).toEqual([ + { + key: 'default|AAPL||', + listing: aapl, + sourceLabels: ['Portfolio'], + }, + { + key: 'default|MSFT||', + listing: msft, + sourceLabels: ['Portfolio'], + }, + ]) + }) + + it('caps heatmap listings after source dedupe', () => { + const items = Array.from({ length: HEATMAP_LISTING_CAP + 1 }, (_, index) => ({ + key: `default|SYM${index}||`, + listing: createListing(`SYM${index}`), + sourceLabels: ['Watchlist'], + })) + + expect(capHeatmapListings(items)).toMatchObject({ + visibleItems: items.slice(0, HEATMAP_LISTING_CAP), + cappedCount: 1, + totalCount: HEATMAP_LISTING_CAP + 1, + }) + }) + + it('omits blank source labels and appends later unique watchlist labels', () => { + const listing = createListing('AAPL') + expect( + resolveWatchlistHeatmapListings([ + { + id: 'list-1', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'One', + isSystem: false, + items: [{ id: 'a', type: 'listing', listing }], + settings: { showLogo: true, showTicker: true, showDescription: true }, + createdAt: '', + updatedAt: '', + }, + { + id: 'list-2', + workspaceId: 'workspace-1', + userId: 'user-1', + name: ' ', + isSystem: false, + items: [{ id: 'b', type: 'listing', listing }], + settings: { showLogo: true, showTicker: true, showDescription: true }, + createdAt: '', + updatedAt: '', + }, + { + id: 'list-3', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Two', + isSystem: false, + items: [{ id: 'c', type: 'listing', listing }], + settings: { showLogo: true, showTicker: true, showDescription: true }, + createdAt: '', + updatedAt: '', + }, + ]) + ).toEqual([ + { + key: 'default|AAPL||', + listing, + sourceLabels: ['One', 'Two'], + }, + ]) + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/source-items.ts b/apps/tradinggoose/widgets/widgets/heatmap/components/source-items.ts new file mode 100644 index 000000000..71cbd93eb --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/source-items.ts @@ -0,0 +1,79 @@ +import { + getListingIdentityKey, + type ListingIdentity, + toListingValueObject, +} from '@/lib/listing/identity' +import { MARKET_QUOTE_SNAPSHOT_REQUEST_CAP } from '@/lib/market/quote-snapshot-contract' +import type { WatchlistRecord } from '@/lib/watchlists/types' + +export const HEATMAP_LISTING_CAP = MARKET_QUOTE_SNAPSHOT_REQUEST_CAP + +export type HeatmapSourceListing = { + key: string + listing: ListingIdentity + sourceLabels: string[] +} + +export const capHeatmapListings = ( + items: HeatmapSourceListing[] +): { + visibleItems: HeatmapSourceListing[] + cappedCount: number + totalCount: number +} => { + const visibleItems = items.slice(0, HEATMAP_LISTING_CAP) + return { + visibleItems, + cappedCount: Math.max(0, items.length - visibleItems.length), + totalCount: items.length, + } +} + +export const resolveWatchlistHeatmapListings = (watchlists: WatchlistRecord[]) => { + const byKey = new Map<string, HeatmapSourceListing>() + + for (const watchlist of watchlists) { + const sourceLabel = watchlist.name.trim() + + for (const item of watchlist.items) { + if (item.type !== 'listing') continue + const listing = toListingValueObject(item.listing) + if (!listing) continue + const key = getListingIdentityKey(listing) + const current = byKey.get(key) + if (current) { + if (sourceLabel && !current.sourceLabels.includes(sourceLabel)) { + current.sourceLabels.push(sourceLabel) + } + continue + } + byKey.set(key, { + key, + listing, + sourceLabels: sourceLabel ? [sourceLabel] : [], + }) + } + } + + return Array.from(byKey.values()) +} + +export const resolvePortfolioHeatmapListings = ( + listings: Array<ListingIdentity | null | undefined> +) => { + const byKey = new Map<string, HeatmapSourceListing>() + + for (const listing of listings) { + const normalized = toListingValueObject(listing) + if (!normalized) continue + const key = getListingIdentityKey(normalized) + if (byKey.has(key)) continue + byKey.set(key, { + key, + listing: normalized, + sourceLabels: ['Portfolio'], + }) + } + + return Array.from(byKey.values()) +} diff --git a/apps/tradinggoose/widgets/widgets/heatmap/index.tsx b/apps/tradinggoose/widgets/widgets/heatmap/index.tsx new file mode 100644 index 000000000..62c14e3cd --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/index.tsx @@ -0,0 +1,16 @@ +'use client' + +import { ChartNoAxesCombined } from 'lucide-react' +import type { DashboardWidgetDefinition } from '@/widgets/types' +import { HeatmapWidgetBody } from '@/widgets/widgets/heatmap/components/body' +import { renderHeatmapHeader } from '@/widgets/widgets/heatmap/components/header' + +export const heatmapWidget: DashboardWidgetDefinition = { + key: 'heatmap', + title: 'Heatmap', + icon: ChartNoAxesCombined, + category: 'trading', + description: 'Watchlist or portfolio market move treemap.', + component: (props) => <HeatmapWidgetBody {...props} />, + renderHeader: renderHeatmapHeader, +} diff --git a/apps/tradinggoose/widgets/widgets/heatmap/types.ts b/apps/tradinggoose/widgets/widgets/heatmap/types.ts new file mode 100644 index 000000000..29e3950af --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/types.ts @@ -0,0 +1,20 @@ +export type HeatmapSourceMode = 'watchlist' | 'portfolio' +export type HeatmapWatchlistSizeMetric = 'volume' | 'volumeUsd' + +export interface HeatmapWidgetParams { + sourceMode?: HeatmapSourceMode + watchlistSizeMetric?: HeatmapWatchlistSizeMetric + marketProvider?: string + marketProviderParams?: Record<string, unknown> + marketAuth?: { + apiKey?: string + apiSecret?: string + [key: string]: unknown + } + tradingProvider?: string + credentialServiceId?: string + accountId?: string + runtime?: { + refreshAt?: number + } +} diff --git a/apps/tradinggoose/widgets/widgets/heatmap/utils/color.test.ts b/apps/tradinggoose/widgets/widgets/heatmap/utils/color.test.ts new file mode 100644 index 000000000..51afd17a2 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/utils/color.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { resolveHeatmapTileColor } from '@/widgets/widgets/heatmap/utils/color' + +describe('resolveHeatmapTileColor', () => { + it('uses deterministic change-percent buckets', () => { + expect(resolveHeatmapTileColor(undefined).bucket).toBe('neutral') + expect(resolveHeatmapTileColor(0).bucket).toBe('neutral') + expect(resolveHeatmapTileColor(0.5).bucket).toBe('gain-low') + expect(resolveHeatmapTileColor(2).bucket).toBe('gain-medium') + expect(resolveHeatmapTileColor(4).bucket).toBe('gain-high') + expect(resolveHeatmapTileColor(-0.5).bucket).toBe('loss-low') + expect(resolveHeatmapTileColor(-2).bucket).toBe('loss-medium') + expect(resolveHeatmapTileColor(-4).bucket).toBe('loss-high') + }) + + it('uses solid background utilities for non-neutral buckets', () => { + for (const changePercent of [0.5, 2, 4, -0.5, -2, -4]) { + const color = resolveHeatmapTileColor(changePercent) + + expect(color.className).toContain('bg-') + expect(color.className).not.toContain('/') + expect(color.className).toContain('text-white') + } + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/utils/color.ts b/apps/tradinggoose/widgets/widgets/heatmap/utils/color.ts new file mode 100644 index 000000000..0600f88bd --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/utils/color.ts @@ -0,0 +1,49 @@ +type HeatmapColorBucket = + | 'neutral' + | 'gain-low' + | 'gain-medium' + | 'gain-high' + | 'loss-low' + | 'loss-medium' + | 'loss-high' + +const HEATMAP_COLOR_CLASS_BY_BUCKET: Record<HeatmapColorBucket, string> = { + neutral: 'border-border bg-muted text-muted-foreground', + 'gain-low': 'border-emerald-600 bg-emerald-600 text-white', + 'gain-medium': 'border-emerald-700 bg-emerald-700 text-white', + 'gain-high': 'border-emerald-800 bg-emerald-800 text-white', + 'loss-low': 'border-red-600 bg-red-600 text-white', + 'loss-medium': 'border-red-700 bg-red-700 text-white', + 'loss-high': 'border-red-800 bg-red-800 text-white', +} + +const resolveMagnitudeBucket = (value: number) => { + const magnitude = Math.abs(value) + if (magnitude >= 3) return 'high' + if (magnitude >= 1) return 'medium' + return 'low' +} + +export const resolveHeatmapTileColor = (changePercent: number | null | undefined) => { + if (typeof changePercent !== 'number' || !Number.isFinite(changePercent)) { + return { + bucket: 'neutral' as const, + className: HEATMAP_COLOR_CLASS_BY_BUCKET.neutral, + } + } + + if (changePercent === 0) { + return { + bucket: 'neutral' as const, + className: HEATMAP_COLOR_CLASS_BY_BUCKET.neutral, + } + } + + const direction = changePercent > 0 ? 'gain' : 'loss' + const bucket = `${direction}-${resolveMagnitudeBucket(changePercent)}` as HeatmapColorBucket + + return { + bucket, + className: HEATMAP_COLOR_CLASS_BY_BUCKET[bucket], + } +} diff --git a/apps/tradinggoose/widgets/widgets/heatmap/utils/format.test.ts b/apps/tradinggoose/widgets/widgets/heatmap/utils/format.test.ts new file mode 100644 index 000000000..bd4b96700 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/utils/format.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { + formatHeatmapChange, + formatHeatmapPercent, + formatHeatmapPrice, +} from '@/widgets/widgets/heatmap/utils/format' + +describe('heatmap format helpers', () => { + it('formats price, percent, and signed change values', () => { + expect(formatHeatmapPrice(123.456)).toBe('123.46') + expect(formatHeatmapPercent(1.234)).toBe('+1.23%') + expect(formatHeatmapChange(1.234)).toBe('+1.234') + expect(formatHeatmapChange(-123.456)).toBe('-123.46') + expect(formatHeatmapChange(null)).toBe('N/A') + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/utils/format.ts b/apps/tradinggoose/widgets/widgets/heatmap/utils/format.ts new file mode 100644 index 000000000..4f8dce5fe --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/utils/format.ts @@ -0,0 +1,14 @@ +export const formatHeatmapPrice = (value: number | null | undefined) => { + if (typeof value !== 'number' || !Number.isFinite(value)) return 'N/A' + return value >= 100 ? value.toFixed(2) : value.toPrecision(4) +} + +export const formatHeatmapPercent = (value: number | null | undefined) => { + if (typeof value !== 'number' || !Number.isFinite(value)) return 'N/A' + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%` +} + +export const formatHeatmapChange = (value: number | null | undefined) => { + if (typeof value !== 'number' || !Number.isFinite(value)) return 'N/A' + return `${value >= 0 ? '+' : '-'}${formatHeatmapPrice(Math.abs(value))}` +} diff --git a/apps/tradinggoose/widgets/widgets/heatmap/utils/treemap-layout.test.ts b/apps/tradinggoose/widgets/widgets/heatmap/utils/treemap-layout.test.ts new file mode 100644 index 000000000..1e54ce16e --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/utils/treemap-layout.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + buildHeatmapTreemapLayout, + type HeatmapTreemapLeafNode, + type HeatmapTreemapNode, +} from '@/widgets/widgets/heatmap/utils/treemap-layout' + +const collectLeafNodes = (node: HeatmapTreemapNode | null): HeatmapTreemapLeafNode[] => { + if (!node) return [] + if (node.type === 'leaf') return [node] + return node.children.flatMap((child) => collectLeafNodes(child)) +} + +describe('buildHeatmapTreemapLayout', () => { + it('builds bounded resizable treemap leaves for listing identities', () => { + const layout = buildHeatmapTreemapLayout({ + width: 320, + height: 180, + items: [ + { + key: 'default|AAPL||', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + resolvedListing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + base: 'AAPL', + name: 'Apple Inc.', + }, + }, + { + key: 'default|MSFT||', + listing: { + listing_id: 'MSFT', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }, + ], + }) + const leaves = collectLeafNodes(layout) + + expect(layout?.type).toBe('group') + expect(leaves).toHaveLength(2) + expect(leaves[0]?.tile.label).toBe('AAPL') + for (const leaf of leaves) { + expect(leaf.width).toBeGreaterThan(0) + expect(leaf.height).toBeGreaterThan(0) + expect(leaf.width).toBeLessThanOrEqual(320) + expect(leaf.height).toBeLessThanOrEqual(180) + } + }) + + it('uses positive size values for relative tile area', () => { + const layout = buildHeatmapTreemapLayout({ + width: 300, + height: 100, + items: [ + { + key: 'default|AAPL||', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + sizeValue: 9, + }, + { + key: 'default|MSFT||', + listing: { + listing_id: 'MSFT', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + sizeValue: 1, + }, + ], + }) + const leaves = collectLeafNodes(layout) + + const apple = leaves.find((leaf) => leaf.tile.key === 'default|AAPL||') + const microsoft = leaves.find((leaf) => leaf.tile.key === 'default|MSFT||') + + expect(apple?.tile.value).toBe(9) + expect(microsoft?.tile.value).toBe(1) + expect((apple?.width ?? 0) * (apple?.height ?? 0)).toBeGreaterThan( + (microsoft?.width ?? 0) * (microsoft?.height ?? 0) + ) + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/utils/treemap-layout.ts b/apps/tradinggoose/widgets/widgets/heatmap/utils/treemap-layout.ts new file mode 100644 index 000000000..1d7c540b4 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/heatmap/utils/treemap-layout.ts @@ -0,0 +1,176 @@ +import type { ListingIdentity, ListingResolved } from '@/lib/listing/identity' +import { getListingIdentityKey } from '@/lib/listing/identity' +import type { MarketQuoteSnapshot } from '@/lib/market/quote-snapshot-contract' + +export type HeatmapTreemapInputItem = { + key: string + listing: ListingIdentity + resolvedListing?: ListingResolved | null + quote?: MarketQuoteSnapshot | null + sizeValue?: number | null + sourceLabels?: string[] +} + +export type HeatmapTreemapTile = HeatmapTreemapInputItem & { + key: string + label: string + name: string + value: number +} + +export type HeatmapTreemapLeafNode = { + type: 'leaf' + key: string + value: number + tile: HeatmapTreemapTile + width: number + height: number +} + +export type HeatmapTreemapGroupNode = { + type: 'group' + key: string + value: number + direction: 'horizontal' | 'vertical' + defaultSizes: [number, number] + children: [HeatmapTreemapNode, HeatmapTreemapNode] +} + +export type HeatmapTreemapNode = HeatmapTreemapLeafNode | HeatmapTreemapGroupNode + +const resolveTileLabel = (item: HeatmapTreemapInputItem) => { + if (item.resolvedListing?.base) return item.resolvedListing.base + if (item.listing.listing_type === 'default') return item.listing.listing_id + return item.listing.quote_id + ? `${item.listing.base_id}/${item.listing.quote_id}` + : item.listing.base_id +} + +const resolveLayoutValue = (item: HeatmapTreemapInputItem) => + typeof item.sizeValue === 'number' && Number.isFinite(item.sizeValue) && item.sizeValue > 0 + ? item.sizeValue + : 1 + +const sumTileValues = (tiles: HeatmapTreemapTile[]) => + tiles.reduce((total, tile) => total + tile.value, 0) + +const findBalancedSplitIndex = (tiles: HeatmapTreemapTile[], totalValue: number) => { + let runningValue = 0 + let splitIndex = 1 + let bestDistance = Number.POSITIVE_INFINITY + + for (let index = 1; index < tiles.length; index += 1) { + runningValue += tiles[index - 1]?.value ?? 0 + const distance = Math.abs(totalValue / 2 - runningValue) + if (distance <= bestDistance) { + bestDistance = distance + splitIndex = index + } + } + + return splitIndex +} + +const prepareHeatmapTreemapTiles = (items: HeatmapTreemapInputItem[]): HeatmapTreemapTile[] => + items + .map((item, index) => { + const key = item.key || getListingIdentityKey(item.listing) + const label = resolveTileLabel(item) + return { + ...item, + key, + label, + name: item.resolvedListing?.name ?? label, + value: resolveLayoutValue(item), + originalIndex: index, + } + }) + .sort((first, second) => { + const valueDifference = second.value - first.value + return valueDifference === 0 ? first.originalIndex - second.originalIndex : valueDifference + }) + .map(({ originalIndex: _originalIndex, ...tile }) => tile) + +const buildTreemapNode = ({ + tiles, + width, + height, + path, +}: { + tiles: HeatmapTreemapTile[] + width: number + height: number + path: string +}): HeatmapTreemapNode => { + const value = sumTileValues(tiles) + + if (tiles.length === 1) { + const tile = tiles[0] + return { + type: 'leaf', + key: tile.key, + value: tile.value, + tile, + width, + height, + } + } + + const splitIndex = findBalancedSplitIndex(tiles, value) + const firstTiles = tiles.slice(0, splitIndex) + const secondTiles = tiles.slice(splitIndex) + const firstValue = sumTileValues(firstTiles) + const secondValue = sumTileValues(secondTiles) + const totalValue = firstValue + secondValue + const firstSize = totalValue > 0 ? (firstValue / totalValue) * 100 : 50 + const secondSize = 100 - firstSize + const direction = width >= height ? 'horizontal' : 'vertical' + const firstWidth = direction === 'horizontal' ? (width * firstSize) / 100 : width + const secondWidth = direction === 'horizontal' ? width - firstWidth : width + const firstHeight = direction === 'vertical' ? (height * firstSize) / 100 : height + const secondHeight = direction === 'vertical' ? height - firstHeight : height + + return { + type: 'group', + key: `heatmap-group-${path}-${direction}-${tiles.length}-${firstSize.toFixed(4)}-${secondSize.toFixed(4)}`, + value, + direction, + defaultSizes: [firstSize, secondSize], + children: [ + buildTreemapNode({ + tiles: firstTiles, + width: firstWidth, + height: firstHeight, + path: `${path}-0`, + }), + buildTreemapNode({ + tiles: secondTiles, + width: secondWidth, + height: secondHeight, + path: `${path}-1`, + }), + ], + } +} + +export const buildHeatmapTreemapLayout = ({ + items, + width, + height, +}: { + items: HeatmapTreemapInputItem[] + width: number + height: number +}) => { + if (width <= 0 || height <= 0 || items.length === 0) return null + + const tiles = prepareHeatmapTreemapTiles(items) + if (tiles.length === 0) return null + + return buildTreemapNode({ + tiles, + width, + height, + path: 'root', + }) +} diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx index a3f3d9af2..ecc589320 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx @@ -191,12 +191,6 @@ export function IndicatorList({ if (isLinkedToColorPair) { setPairContext(resolvedPairColor, { indicatorId: null, - reviewTarget: { - reviewSessionId: null, - reviewEntityKind: 'indicator', - reviewEntityId: null, - reviewDraftSessionId: draftSessionId, - }, }) } else if (onWidgetParamsChange) { const currentParams = diff --git a/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/workflow-item.tsx b/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/workflow-item.tsx index 4b4cbd00a..b7571230b 100644 --- a/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/workflow-item.tsx +++ b/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/workflow-item.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx' import { Copy, Pencil, Trash2, Workflow } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' import { shallow } from 'zustand/shallow' import { AlertDialog, @@ -19,6 +20,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console/logger' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { localizeHref, type LocaleCode } from '@/i18n/utils' import { useFolderStore, useIsWorkflowSelected } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -84,6 +86,7 @@ export function WorkflowItem({ const dragStartedRef = useRef(false) const inputRef = useRef<HTMLInputElement>(null) const router = useRouter() + const locale = useLocale() as LocaleCode const workspaceId = useWorkspaceId() const { selectedWorkflows, selectOnly, toggleWorkflowSelection } = useFolderStore() const isSelected = useIsWorkflowSelected(workflow.id) @@ -258,7 +261,7 @@ export function WorkflowItem({ } if (workspaceId) { - router.push(`/workspace/${workspaceId}/dashboard`) + router.push(localizeHref(locale, `/workspace/${workspaceId}/dashboard`)) } else if (duplicatedWorkflow && onSelect) { onSelect(duplicatedWorkflow) } @@ -276,6 +279,7 @@ export function WorkflowItem({ router, userPermissions.canEdit, workflow.id, + locale, workspaceId, ]) @@ -428,7 +432,7 @@ export function WorkflowItem({ </button> ) : ( <Link - href={`/workspace/${workspaceId}/dashboard`} + href={localizeHref(locale, `/workspace/${workspaceId}/dashboard`)} className='flex min-w-0 flex-1 items-center gap-2' onClick={handleClick} draggable={false} diff --git a/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx b/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx index 632dfa028..816762010 100644 --- a/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react' import { Download, Folder, Plus } from 'lucide-react' +import { useLocale } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -14,6 +15,8 @@ import { generateFolderName } from '@/lib/naming' import { cn } from '@/lib/utils' import { importWorkflowFromJsonContent } from '@/lib/workflows/import' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { useImportSkills } from '@/hooks/queries/skills' import { useFolderStore } from '@/stores/folders/store' import { parseWorkflowJson } from '@/stores/workflows/json/importer' @@ -38,6 +41,8 @@ export function DashboardWorkflowCreateMenu({ workspaceId, onWorkflowCreated, }: DashboardWorkflowCreateMenuProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.workflowCreateMenu const [isCreatingFolder, setIsCreatingFolder] = useState(false) const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false) const [isImporting, setIsImporting] = useState(false) @@ -78,7 +83,7 @@ export function DashboardWorkflowCreateMenu({ setIsCreatingFolder(true) try { - const folderName = await generateFolderName(workspaceId) + const folderName = await generateFolderName(workspaceId, locale) await createFolder({ name: folderName, workspaceId }) logger.info(`Created folder ${folderName} from dashboard widget`) } catch (error) { @@ -86,7 +91,7 @@ export function DashboardWorkflowCreateMenu({ } finally { setIsCreatingFolder(false) } - }, [workspaceId, isCreatingFolder, createFolder]) + }, [workspaceId, isCreatingFolder, createFolder, locale]) const handleDirectImport = useCallback( async (content: string, filename?: string) => { @@ -221,8 +226,8 @@ export function DashboardWorkflowCreateMenu({ const createFolderDisabled = !isWorkspaceReady || isMenuDisabled || isCreatingFolder const importWorkflowDisabled = !isWorkspaceReady || isMenuDisabled || isImporting const createButtonTooltip = isWorkspaceReady - ? 'Create folder or workflow' - : 'Select a workspace to create workflows' + ? copy.createButtonTooltip + : copy.selectWorkspaceTooltip return ( <> @@ -237,7 +242,7 @@ export function DashboardWorkflowCreateMenu({ disabled={isMenuDisabled} > <Plus className={widgetHeaderMenuIconClassName} /> - <span className='sr-only'>Create workflow</span> + <span className='sr-only'>{copy.createButtonTooltip}</span> </button> </DropdownMenuTrigger> </span> @@ -259,7 +264,7 @@ export function DashboardWorkflowCreateMenu({ > <Plus className={widgetHeaderMenuTextClassName} /> <span className={widgetHeaderMenuTextClassName}> - {isCreatingWorkflow ? 'Creating...' : 'New workflow'} + {isCreatingWorkflow ? copy.creating : copy.createWorkflow} </span> </DropdownMenuItem> @@ -273,7 +278,7 @@ export function DashboardWorkflowCreateMenu({ > <Folder className={widgetHeaderMenuTextClassName} /> <span className={widgetHeaderMenuTextClassName}> - {isCreatingFolder ? 'Creating...' : 'New folder'} + {isCreatingFolder ? copy.creating : copy.createFolder} </span> </DropdownMenuItem> @@ -287,7 +292,7 @@ export function DashboardWorkflowCreateMenu({ > <Download className={widgetHeaderMenuTextClassName} /> <span className={widgetHeaderMenuTextClassName}> - {isImporting ? 'Importing...' : 'Import workflow'} + {isImporting ? copy.importing : copy.importWorkflow} </span> </DropdownMenuItem> </DropdownMenuContent> diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.test.tsx b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.test.tsx new file mode 100644 index 000000000..3255ebc8f --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.test.tsx @@ -0,0 +1,748 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PortfolioSnapshotWidgetBody } from '@/widgets/widgets/portfolio_snapshot/components/body' + +const mockUseOAuthProviderAvailability = vi.fn() +const mockUseOAuthCredentialsByProviderIds = vi.fn() +const mockUseMarketQuoteSnapshots = vi.fn() +const mockUseTradingAccounts = vi.fn() +const mockUseTradingPortfolioSnapshot = vi.fn() +const mockUseTradingPortfolioPerformance = vi.fn() +const mockEmitPortfolioSnapshotParamsChange = vi.fn() + +vi.mock('@/hooks/queries/oauth-provider-availability', () => ({ + useOAuthProviderAvailability: (...args: unknown[]) => mockUseOAuthProviderAvailability(...args), +})) + +vi.mock('@/hooks/queries/oauth-credentials', () => ({ + useOAuthCredentialsByProviderIds: (...args: unknown[]) => + mockUseOAuthCredentialsByProviderIds(...args), +})) + +vi.mock('@/hooks/queries/market-quote-snapshots', () => ({ + useMarketQuoteSnapshots: (...args: unknown[]) => mockUseMarketQuoteSnapshots(...args), +})) + +vi.mock('@/hooks/queries/trading-portfolio', () => ({ + useTradingAccounts: (...args: unknown[]) => mockUseTradingAccounts(...args), + useTradingPortfolioSnapshot: (...args: unknown[]) => mockUseTradingPortfolioSnapshot(...args), + useTradingPortfolioPerformance: (...args: unknown[]) => + mockUseTradingPortfolioPerformance(...args), +})) + +vi.mock('@/widgets/utils/portfolio-snapshot-params', () => ({ + emitPortfolioSnapshotParamsChange: (...args: unknown[]) => + mockEmitPortfolioSnapshotParamsChange(...args), + usePortfolioSnapshotParamsPersistence: vi.fn(), +})) + +vi.mock('@/widgets/widgets/portfolio_snapshot/components/performance-chart', () => ({ + PortfolioSnapshotPerformanceChart: () => <div>performance-chart</div>, +})) + +const createQueryResult = <T,>(overrides: Partial<T> = {}) => + ({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + ...overrides, + }) as T + +describe('PortfolioSnapshotWidgetBody', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + mockUseOAuthProviderAvailability.mockReturnValue( + createQueryResult({ + data: { + 'alpaca-live': true, + 'alpaca-paper': true, + tradier: true, + }, + }) + ) + mockUseOAuthCredentialsByProviderIds.mockReturnValue( + createQueryResult({ + data: { + 'alpaca-live': [{ id: 'cred-1', name: 'Alpaca Live', provider: 'alpaca-live' }], + tradier: [{ id: 'cred-2', name: 'Tradier', provider: 'tradier' }], + }, + }) + ) + mockUseTradingAccounts.mockReturnValue( + createQueryResult({ + data: [{ id: 'acct-1', name: 'Paper', type: 'paper', baseCurrency: 'USD' }], + }) + ) + mockUseMarketQuoteSnapshots.mockReturnValue( + createQueryResult({ + data: { + 'default|TG_LSTG_AAPL||': { + lastPrice: 110, + previousClose: 100, + change: 10, + changePercent: 10, + }, + }, + }) + ) + mockUseTradingPortfolioSnapshot.mockReturnValue( + createQueryResult({ + data: { + asOf: '2026-04-22T15:30:00.000Z', + provider: { name: 'Alpaca' }, + account: { + id: 'acct-1', + name: 'Paper', + type: 'paper', + baseCurrency: 'USD', + status: 'active', + }, + cashBalances: [], + positions: [ + { + symbol: { + base: 'AAPL', + quote: 'USD', + assetClass: 'stock', + active: true, + rank: 0, + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }, + quantity: 10, + }, + ], + orders: [], + accountSummary: { + totalPortfolioValue: 10000, + totalCashValue: 2500, + totalHoldingsValue: 7500, + buyingPower: 15000, + totalUnrealizedPnl: 100, + }, + }, + positionListings: [ + { + listing: { + listing_id: 'TG_LSTG_AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + grossQuantity: 10, + signedQuantity: 10, + }, + ], + }) + ) + mockUseTradingPortfolioPerformance.mockReturnValue( + createQueryResult({ + data: { + window: '1D', + supportedWindows: ['1D', '1W', '1M', '3M', 'YTD', '1Y'], + series: [ + { timestamp: '2026-04-21T00:00:00.000Z', equity: 10000 }, + { timestamp: '2026-04-22T00:00:00.000Z', equity: 10100 }, + ], + summary: { + currency: 'USD', + startEquity: 10000, + endEquity: 10100, + highEquity: 10100, + lowEquity: 10000, + absoluteReturn: 100, + percentReturn: 1, + asOf: '2026-04-22T00:00:00.000Z', + }, + }, + }) + ) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('auto-selects and immediately uses the single returned account when none is persisted', async () => { + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + selectedWindow: '1D', + }} + /> + ) + }) + + expect(mockEmitPortfolioSnapshotParamsChange).toHaveBeenCalledWith({ + params: { accountId: 'acct-1', credentialServiceId: 'alpaca-live' }, + panelId: 'panel-1', + widgetKey: 'portfolio_snapshot', + }) + expect(mockUseTradingPortfolioSnapshot).toHaveBeenCalledWith({ + workspaceId: undefined, + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'acct-1', + }) + expect(mockUseTradingPortfolioPerformance).toHaveBeenCalledWith({ + workspaceId: undefined, + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'acct-1', + selectedWindow: '1D', + }) + }) + + it('clears provider-scoped state when it normalizes an invalid provider', async () => { + const params = { + provider: 'unsupported-provider', + accountId: 'acct-stale', + selectedWindow: '1D', + } as const + + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={params} + /> + ) + }) + + expect(mockEmitPortfolioSnapshotParamsChange).toHaveBeenCalledWith({ + params: { + provider: null, + accountId: null, + credentialServiceId: null, + selectedWindow: null, + }, + panelId: 'panel-1', + widgetKey: 'portfolio_snapshot', + }) + expect(mockUseTradingAccounts).toHaveBeenCalledWith({ + workspaceId: undefined, + provider: undefined, + credentialServiceId: undefined, + enabled: false, + }) + expect(container.textContent).toContain('Select a trading provider to get started.') + }) + + it('falls back to the provider-supported window list', async () => { + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: 'MAX', + }} + /> + ) + }) + + expect(mockEmitPortfolioSnapshotParamsChange).toHaveBeenCalledWith({ + params: { selectedWindow: '1D' }, + panelId: 'panel-1', + widgetKey: 'portfolio_snapshot', + }) + }) + + it('renders performance windows from the selected trading provider', async () => { + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'tradier', + accountId: 'acct-1', + selectedWindow: 'MAX', + }} + /> + ) + }) + + const windows = Array.from(container.querySelectorAll('[role="tab"]')).map((button) => + button.textContent?.trim() + ) + + expect(windows).toEqual(['1W', '1M', 'YTD', '1Y', 'MAX']) + expect(windows).not.toContain('1D') + expect(mockUseTradingPortfolioPerformance).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'tradier', + selectedWindow: 'MAX', + }) + ) + }) + + it('preserves a saved account when the accounts query errors', async () => { + mockUseTradingAccounts.mockReturnValue( + createQueryResult({ + data: [], + error: new Error('accounts fetch failed'), + }) + ) + + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: '1D', + }} + /> + ) + }) + + expect(mockEmitPortfolioSnapshotParamsChange).not.toHaveBeenCalledWith({ + params: { accountId: null }, + panelId: 'panel-1', + widgetKey: 'portfolio_snapshot', + }) + }) + + it('renders the no-accounts empty state when the broker returns zero accounts', async () => { + mockUseTradingAccounts.mockReturnValue( + createQueryResult({ + data: [], + }) + ) + + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + selectedWindow: '1D', + }} + /> + ) + }) + + expect(container.textContent).toContain( + 'No broker accounts found for this provider connection.' + ) + }) + + it('renders the loaded performance and summary state', async () => { + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: '1D', + marketProvider: 'alpaca', + marketAuth: { apiKey: '{{ ALPACA_API_KEY }}' }, + }} + /> + ) + }) + + expect(container.textContent).toContain('Performance') + expect(container.textContent).toContain('Current Summary') + expect(container.textContent).toContain('Portfolio Value') + expect(container.textContent).toContain('Market Quotes') + expect(container.textContent).toContain('Quote Value') + expect(container.textContent).toContain('Day Change') + expect(container.textContent).toContain('Day %') + expect(container.textContent).toContain('Quoted Positions') + expect(container.textContent).toContain('Alpaca · active · paper') + expect(container.textContent).toContain('performance-chart') + expect(mockUseTradingPortfolioSnapshot).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'acct-1', + }) + expect(mockUseMarketQuoteSnapshots).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + provider: 'alpaca', + items: [ + { + key: 'default|TG_LSTG_AAPL||', + listing: { + listing_id: 'TG_LSTG_AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }, + ], + auth: { apiKey: '{{ ALPACA_API_KEY }}' }, + providerParams: undefined, + refreshKey: null, + enabled: true, + }) + }) + + it('does not use trading provider settings as market quote provider settings', async () => { + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: '1D', + }} + /> + ) + }) + + expect(mockEmitPortfolioSnapshotParamsChange).not.toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + marketProvider: expect.any(String), + }), + }) + ) + expect(mockUseMarketQuoteSnapshots).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + provider: undefined, + items: [ + { + key: 'default|TG_LSTG_AAPL||', + listing: { + listing_id: 'TG_LSTG_AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }, + ], + auth: undefined, + providerParams: undefined, + refreshKey: null, + enabled: false, + }) + }) + + it('uses signed quantity for quote-backed day change', async () => { + mockUseTradingPortfolioSnapshot.mockReturnValue( + createQueryResult({ + data: { + asOf: '2026-04-22T15:30:00.000Z', + provider: { name: 'Alpaca' }, + account: { + id: 'acct-1', + name: 'Paper', + type: 'paper', + baseCurrency: 'USD', + status: 'active', + }, + cashBalances: [], + positions: [ + { + symbol: { + base: 'TSLA', + quote: 'USD', + assetClass: 'stock', + active: true, + rank: 0, + listing: { + listing_id: 'TSLA', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }, + quantity: -5, + }, + ], + orders: [], + accountSummary: { + totalPortfolioValue: 10000, + totalCashValue: 2500, + totalHoldingsValue: 7500, + buyingPower: 15000, + totalUnrealizedPnl: 100, + }, + }, + positionListings: [ + { + listing: { + listing_id: 'TG_LSTG_TSLA', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + grossQuantity: 5, + signedQuantity: -5, + }, + ], + }) + ) + mockUseMarketQuoteSnapshots.mockReturnValue( + createQueryResult({ + data: { + 'default|TG_LSTG_TSLA||': { + lastPrice: 110, + previousClose: 100, + change: 10, + changePercent: 10, + }, + }, + }) + ) + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: '1D', + marketProvider: 'alpaca', + }} + /> + ) + }) + + expect(container.textContent).toContain('$550.00') + expect(container.textContent).toContain('-$50.00') + expect(container.textContent).toContain('-8.33%') + }) + + it('keeps broker snapshot visible when market quotes fail', async () => { + mockUseMarketQuoteSnapshots.mockReturnValue( + createQueryResult({ + error: new Error('quotes failed'), + }) + ) + + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + context={{ workspaceId: 'workspace-1' }} + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: '1D', + marketProvider: 'alpaca', + }} + /> + ) + }) + + expect(container.textContent).toContain('Performance') + expect(container.textContent).toContain('Current Summary') + expect(container.textContent).toContain('quotes failed') + expect(container.textContent).toContain('Quote Value') + }) + + it('renders the explicit performance unavailable state', async () => { + mockUseTradingPortfolioPerformance.mockReturnValue( + createQueryResult({ + data: { + window: '1D', + supportedWindows: ['1D', '1W', '1M', '3M', 'YTD', '1Y'], + series: [], + summary: null, + unavailableReason: 'No usable performance data returned by broker', + }, + }) + ) + + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: '1D', + }} + /> + ) + }) + + expect(container.textContent).toContain('No usable performance data returned by broker') + }) + + it('refetches snapshot and performance when runtime.refreshAt changes', async () => { + const snapshotRefetch = vi.fn() + const performanceRefetch = vi.fn() + + mockUseTradingPortfolioSnapshot.mockReturnValue( + createQueryResult({ + data: { + asOf: '2026-04-22T15:30:00.000Z', + account: { id: 'acct-1', name: 'Paper', type: 'paper', baseCurrency: 'USD' }, + cashBalances: [], + positions: [], + orders: [], + accountSummary: { + totalPortfolioValue: 10000, + totalCashValue: 2500, + }, + }, + refetch: snapshotRefetch, + positionListings: [], + }) + ) + mockUseTradingPortfolioPerformance.mockReturnValue( + createQueryResult({ + data: { + window: '1D', + supportedWindows: ['1D', '1W', '1M', '3M', 'YTD', '1Y'], + series: [], + summary: null, + unavailableReason: 'No usable performance data returned by broker', + }, + refetch: performanceRefetch, + }) + ) + + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: '1D', + }} + /> + ) + }) + + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: '1D', + runtime: { + refreshAt: 123, + }, + }} + /> + ) + }) + + expect(snapshotRefetch).toHaveBeenCalledTimes(1) + expect(performanceRefetch).toHaveBeenCalledTimes(1) + }) + + it('shows the no-provider-configured state when trading providers are unavailable', async () => { + mockUseOAuthProviderAvailability.mockReturnValue( + createQueryResult({ + data: {}, + }) + ) + mockUseTradingAccounts.mockReturnValue( + createQueryResult({ + data: [], + }) + ) + + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{ + provider: 'alpaca', + selectedWindow: '1D', + }} + /> + ) + }) + + expect(container.textContent).toContain('No trading providers are configured.') + expect(mockUseTradingAccounts).toHaveBeenCalledWith({ + workspaceId: undefined, + provider: undefined, + credentialServiceId: undefined, + enabled: false, + }) + expect(mockUseTradingPortfolioSnapshot).toHaveBeenCalledWith({ + workspaceId: undefined, + provider: undefined, + credentialServiceId: undefined, + accountId: undefined, + }) + }) + + it('requires selecting a provider before loading credentials or accounts', async () => { + mockUseTradingAccounts.mockReturnValueOnce(createQueryResult({ data: [] })) + + await act(async () => { + root.render( + <PortfolioSnapshotWidgetBody + widget={{ key: 'portfolio_snapshot' } as any} + panelId='panel-1' + params={{}} + /> + ) + }) + + expect(container.textContent).toContain('Select a trading provider to get started.') + expect(mockUseTradingAccounts).toHaveBeenCalledWith({ + workspaceId: undefined, + provider: undefined, + credentialServiceId: undefined, + enabled: false, + }) + expect(mockUseTradingPortfolioSnapshot).toHaveBeenCalledWith({ + workspaceId: undefined, + provider: undefined, + credentialServiceId: undefined, + accountId: undefined, + }) + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.tsx b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.tsx new file mode 100644 index 000000000..c6d79d406 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.tsx @@ -0,0 +1,735 @@ +'use client' + +import { type ReactNode, useEffect, useMemo, useRef } from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Empty, EmptyDescription, EmptyHeader } from '@/components/ui/empty' +import { LoadingAgent } from '@/components/ui/loading-agent' +import { Separator } from '@/components/ui/separator' +import { getListingIdentityKey } from '@/lib/listing/identity' +import { MARKET_QUOTE_SNAPSHOT_REQUEST_CAP } from '@/lib/market/quote-snapshot-contract' +import { cn } from '@/lib/utils' +import { useMarketQuoteSnapshots } from '@/hooks/queries/market-quote-snapshots' +import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' +import { + useTradingAccounts, + useTradingPortfolioPerformance, + useTradingPortfolioSnapshot, +} from '@/hooks/queries/trading-portfolio' +import { getTradingProviderDefinition } from '@/providers/trading/providers' +import type { TradingPortfolioPerformanceWindow } from '@/providers/trading/types' +import type { WidgetComponentProps } from '@/widgets/types' +import { + emitPortfolioSnapshotParamsChange, + usePortfolioSnapshotParamsPersistence, +} from '@/widgets/utils/portfolio-snapshot-params' +import { useTradingCredentialServices } from '@/widgets/widgets/components/trading-credential-services' +import { PortfolioSnapshotPerformanceChart } from '@/widgets/widgets/portfolio_snapshot/components/performance-chart' +import { + getPortfolioSnapshotDefaultWindow, + getPortfolioSnapshotMarketProviderOptions, + getPortfolioSnapshotProviderAvailabilityIds, + getPortfolioSnapshotProviderOptions, + getPortfolioSnapshotSupportedWindows, + resolvePortfolioSnapshotMarketProviderId, + resolvePortfolioSnapshotProviderId, +} from '@/widgets/widgets/portfolio_snapshot/components/shared' +import type { PortfolioSnapshotWidgetParams } from '@/widgets/widgets/portfolio_snapshot/types' + +const PortfolioMessage = ({ message }: { message: string }) => ( + <Empty className='h-full min-h-[180px] rounded-none border-0 bg-transparent p-4'> + <EmptyHeader> + <EmptyDescription>{message}</EmptyDescription> + </EmptyHeader> + </Empty> +) + +const PortfolioLoading = ({ + size = 'md', + className, +}: { + size?: 'sm' | 'md' + className?: string +}) => ( + <div className={cn('flex h-full min-h-[180px] items-center justify-center', className)}> + <LoadingAgent size={size} /> + </div> +) + +const CALENDAR_DAY_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T(?:00:00:00\.000Z|12:00:00\.000Z)$/ +const PORTFOLIO_SNAPSHOT_QUOTE_CAP = MARKET_QUOTE_SNAPSHOT_REQUEST_CAP + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) + +const formatCurrency = (value: number | null | undefined, currency = 'USD') => { + if (!isFiniteNumber(value)) { + return 'N/A' + } + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(value) +} + +const formatPercent = (value: number | null | undefined) => { + if (!isFiniteNumber(value)) { + return 'N/A' + } + + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%` +} + +const formatSignedCurrency = (value: number | null | undefined, currency = 'USD') => { + if (!isFiniteNumber(value)) { + return 'N/A' + } + + const formatted = formatCurrency(Math.abs(value), currency) + return `${value >= 0 ? '+' : '-'}${formatted}` +} + +const formatAsOf = (timestamp: string | undefined) => { + if (!timestamp) return 'N/A' + const parsed = Date.parse(timestamp) + if (!Number.isFinite(parsed)) return 'N/A' + if (CALENDAR_DAY_TIMESTAMP_PATTERN.test(timestamp)) { + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeZone: 'UTC', + }).format(new Date(parsed)) + } + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(parsed)) +} + +type MetricTone = 'neutral' | 'positive' | 'negative' | 'warning' + +const metricToneClassName: Record<MetricTone, string> = { + neutral: 'text-foreground', + positive: 'text-emerald-600 dark:text-emerald-400', + negative: 'text-red-600 dark:text-red-400', + warning: 'text-amber-600 dark:text-amber-300', +} + +const getNumberTone = (value: number | null | undefined): MetricTone => { + if (!isFiniteNumber(value) || value === 0) return 'neutral' + return value > 0 ? 'positive' : 'negative' +} + +const MetricGroup = ({ children, className }: { children: ReactNode; className?: string }) => ( + <div + className={cn( + 'grid gap-px overflow-hidden rounded-md border border-border/60 bg-border/60 [grid-template-columns:repeat(auto-fit,minmax(min(100%,7.5rem),1fr))]', + className + )} + > + {children} + </div> +) + +const MetricTile = ({ + label, + value, + hint, + tone = 'neutral', +}: { + label: string + value: string + hint?: string + tone?: MetricTone +}) => ( + <div className='min-w-0 bg-background/80 px-3 py-2.5'> + <div className='text-[10px] text-muted-foreground uppercase leading-4 tracking-[0.08em] [overflow-wrap:anywhere]'> + {label} + </div> + <div + className={cn( + 'mt-0.5 font-medium font-mono text-sm tabular-nums leading-5 [overflow-wrap:anywhere]', + metricToneClassName[tone] + )} + > + {value} + </div> + {hint ? ( + <div className='mt-0.5 text-[11px] text-muted-foreground leading-4 [overflow-wrap:anywhere]'> + {hint} + </div> + ) : null} + </div> +) + +export function PortfolioSnapshotWidgetBody({ + context, + panelId, + widget, + params, + onWidgetParamsChange, +}: WidgetComponentProps) { + const workspaceId = context?.workspaceId ?? null + const widgetKey = widget?.key ?? 'portfolio_snapshot' + const widgetParams = + params && typeof params === 'object' ? (params as PortfolioSnapshotWidgetParams) : null + const providerAvailabilityQuery = useOAuthProviderAvailability( + getPortfolioSnapshotProviderAvailabilityIds() + ) + const providerOptions = useMemo( + () => getPortfolioSnapshotProviderOptions(providerAvailabilityQuery.data), + [providerAvailabilityQuery.data] + ) + const marketProviderOptions = useMemo(() => getPortfolioSnapshotMarketProviderOptions(), []) + const providerId = resolvePortfolioSnapshotProviderId(widgetParams, providerOptions) + const marketProviderId = resolvePortfolioSnapshotMarketProviderId( + widgetParams, + marketProviderOptions + ) + const marketProviderName = + marketProviderOptions.find((option) => option.id === marketProviderId)?.name ?? marketProviderId + const hasSelectedProvider = Boolean(providerId) + const hasValidPersistedProvider = + Boolean(widgetParams?.provider) && widgetParams?.provider === providerId + const hasInvalidPersistedProvider = + !providerAvailabilityQuery.isLoading && + !providerAvailabilityQuery.error && + Boolean(widgetParams?.provider) && + !hasSelectedProvider + const providerDefinition = hasSelectedProvider ? getTradingProviderDefinition(providerId) : null + const supportedWindows = useMemo( + () => (hasSelectedProvider ? getPortfolioSnapshotSupportedWindows(providerId) : []), + [hasSelectedProvider, providerId] + ) + const defaultWindow = hasSelectedProvider + ? getPortfolioSnapshotDefaultWindow(providerId) + : undefined + const isProviderReady = + !providerAvailabilityQuery.isLoading && + !providerAvailabilityQuery.error && + hasSelectedProvider && + providerOptions.length > 0 + const refreshAt = isFiniteNumber(widgetParams?.runtime?.refreshAt) + ? widgetParams.runtime.refreshAt + : null + const lastRefreshAtRef = useRef<number | null>(null) + + usePortfolioSnapshotParamsPersistence({ + onWidgetParamsChange, + panelId, + widget, + params: params && typeof params === 'object' ? (params as Record<string, unknown>) : null, + }) + + useEffect(() => { + if (!hasInvalidPersistedProvider) return + emitPortfolioSnapshotParamsChange({ + params: { + provider: null, + credentialServiceId: null, + accountId: null, + selectedWindow: null, + }, + panelId, + widgetKey, + }) + }, [hasInvalidPersistedProvider, panelId, widgetKey]) + + const selectedWindow = + widgetParams?.selectedWindow && supportedWindows.includes(widgetParams.selectedWindow) + ? widgetParams.selectedWindow + : defaultWindow + + useEffect(() => { + if (providerAvailabilityQuery.isLoading) return + if (providerAvailabilityQuery.error) return + if (!hasSelectedProvider) return + if (!hasValidPersistedProvider) return + if (!selectedWindow) return + if (widgetParams?.selectedWindow === selectedWindow) return + emitPortfolioSnapshotParamsChange({ + params: { selectedWindow }, + panelId, + widgetKey, + }) + }, [ + hasSelectedProvider, + hasValidPersistedProvider, + panelId, + providerAvailabilityQuery.error, + providerAvailabilityQuery.isLoading, + selectedWindow, + widgetKey, + widgetParams?.selectedWindow, + ]) + + const credentialServices = useTradingCredentialServices({ + providerId, + credentialServiceId: widgetParams?.credentialServiceId, + enabled: isProviderReady, + }) + const activeCredentialServiceId = credentialServices.activeServiceId + const accountsQuery = useTradingAccounts({ + workspaceId: workspaceId ?? undefined, + provider: isProviderReady ? providerId : undefined, + credentialServiceId: activeCredentialServiceId, + enabled: Boolean(activeCredentialServiceId), + }) + const accounts = accountsQuery.data ?? [] + const singleAccount = accounts.length === 1 ? (accounts[0] ?? null) : null + const activeAccountId = activeCredentialServiceId + ? (widgetParams?.accountId ?? singleAccount?.id) + : undefined + + useEffect(() => { + if (accountsQuery.isLoading) return + if (accountsQuery.error) return + + if (accounts.length === 1) { + const onlyAccount = accounts[0] + if (!onlyAccount) return + if (widgetParams?.accountId) return + emitPortfolioSnapshotParamsChange({ + params: { + accountId: onlyAccount.id, + credentialServiceId: activeCredentialServiceId, + }, + panelId, + widgetKey, + }) + } + }, [ + accounts, + accountsQuery.error, + accountsQuery.isLoading, + activeCredentialServiceId, + panelId, + widgetKey, + widgetParams?.accountId, + ]) + + const snapshotQuery = useTradingPortfolioSnapshot({ + workspaceId: workspaceId ?? undefined, + provider: isProviderReady ? providerId : undefined, + credentialServiceId: activeCredentialServiceId, + accountId: activeAccountId, + }) + + const quotePositions = useMemo( + () => + snapshotQuery.positionListings.map((position) => ({ + ...position, + key: getListingIdentityKey(position.listing), + })), + [snapshotQuery.positionListings] + ) + const cappedQuotePositions = useMemo( + () => quotePositions.slice(0, PORTFOLIO_SNAPSHOT_QUOTE_CAP), + [quotePositions] + ) + + const quoteItems = useMemo( + () => + cappedQuotePositions.map((position) => ({ + key: position.key, + listing: position.listing, + })), + [cappedQuotePositions] + ) + const quoteSnapshotsQuery = useMarketQuoteSnapshots({ + workspaceId: workspaceId ?? undefined, + provider: marketProviderId || undefined, + items: quoteItems, + auth: widgetParams?.marketAuth, + providerParams: widgetParams?.marketProviderParams, + refreshKey: refreshAt, + enabled: Boolean(marketProviderId && activeAccountId && quoteItems.length > 0), + }) + + const performanceQuery = useTradingPortfolioPerformance({ + workspaceId: workspaceId ?? undefined, + provider: isProviderReady ? providerId : undefined, + credentialServiceId: activeCredentialServiceId, + accountId: activeAccountId, + selectedWindow: selectedWindow as TradingPortfolioPerformanceWindow | undefined, + }) + + useEffect(() => { + if (refreshAt == null) return + if (lastRefreshAtRef.current === refreshAt) return + lastRefreshAtRef.current = refreshAt + if (activeAccountId) { + void snapshotQuery.refetch() + void performanceQuery.refetch() + } + }, [activeAccountId, performanceQuery, refreshAt, snapshotQuery]) + + if (providerAvailabilityQuery.isLoading) { + return <PortfolioLoading /> + } + + if (providerAvailabilityQuery.error) { + return ( + <PortfolioMessage + message={ + providerAvailabilityQuery.error instanceof Error + ? providerAvailabilityQuery.error.message + : 'Failed to load trading providers.' + } + /> + ) + } + + if (!providerId || providerOptions.length === 0) { + if (providerOptions.length === 0) { + return <PortfolioMessage message='No trading providers are configured.' /> + } + + return <PortfolioMessage message='Select a trading provider to get started.' /> + } + + if (!activeAccountId) { + if (credentialServices.isLoading) { + return <PortfolioLoading /> + } + + if (!activeCredentialServiceId) { + return ( + <PortfolioMessage message='Select a broker connection to load this portfolio snapshot.' /> + ) + } + + if (accountsQuery.isLoading && accounts.length === 0) { + return <PortfolioLoading /> + } + + if (accountsQuery.error) { + return ( + <PortfolioMessage + message={ + accountsQuery.error instanceof Error + ? accountsQuery.error.message + : 'Failed to load broker accounts.' + } + /> + ) + } + + if (accounts.length === 0) { + return <PortfolioMessage message='No broker accounts found for this provider connection.' /> + } + + return <PortfolioMessage message='Select a broker account to load this portfolio snapshot.' /> + } + + if (snapshotQuery.isLoading && !snapshotQuery.data) { + return <PortfolioLoading /> + } + + if (snapshotQuery.error || !snapshotQuery.data) { + return ( + <PortfolioMessage + message={ + snapshotQuery.error instanceof Error + ? snapshotQuery.error.message + : 'Failed to load portfolio snapshot.' + } + /> + ) + } + + const snapshot = snapshotQuery.data + const performance = performanceQuery.data + const currency = performance?.summary?.currency ?? snapshot.account.baseCurrency ?? 'USD' + const activeWindows = supportedWindows + const quoteErrorMessage = + quoteSnapshotsQuery.error instanceof Error + ? quoteSnapshotsQuery.error.message + : quoteSnapshotsQuery.error + ? 'Failed to load market quotes.' + : null + const quoteSummary = cappedQuotePositions.reduce( + (summary, position) => { + const quote = quoteSnapshotsQuery.data?.[position.key] + const lastPrice = quote?.lastPrice + const previousClose = quote?.previousClose + const change = quote?.change + if (!isFiniteNumber(lastPrice) || !isFiniteNumber(previousClose)) { + return summary + } + + const perUnitDayChange = isFiniteNumber(change) ? change : lastPrice - previousClose + summary.quoteValue += lastPrice * position.grossQuantity + summary.dayChange += perUnitDayChange * position.signedQuantity + summary.quotedPositions += 1 + return summary + }, + { dayChange: 0, quoteValue: 0, quotedPositions: 0 } + ) + const quoteDayChange = quoteSummary.quotedPositions > 0 ? quoteSummary.dayChange : null + const quotePreviousValue = isFiniteNumber(quoteDayChange) + ? quoteSummary.quoteValue - quoteDayChange + : null + const quoteDayPercent = + isFiniteNumber(quoteDayChange) && isFiniteNumber(quotePreviousValue) && quotePreviousValue !== 0 + ? (quoteDayChange / quotePreviousValue) * 100 + : null + const quotedPositionsHint = + quotePositions.length > cappedQuotePositions.length + ? `Quote metrics use first ${PORTFOLIO_SNAPSHOT_QUOTE_CAP} of ${quotePositions.length} holdings` + : undefined + const quoteStatusText = + quoteErrorMessage ?? + (quoteSnapshotsQuery.isLoading && !quoteSnapshotsQuery.data + ? 'Loading quotes' + : quoteSnapshotsQuery.isFetching + ? 'Refreshing quotes' + : (quotedPositionsHint ?? + (marketProviderId + ? quoteItems.length > 0 + ? `${quoteSummary.quotedPositions}/${cappedQuotePositions.length} quoted` + : 'No holdings with market listings' + : 'No market provider'))) + const accountMetaText = [ + snapshot.provider?.name ?? providerDefinition?.name ?? providerId, + snapshot.account.status ?? 'unknown', + snapshot.account.type, + ].join(' · ') + const performanceTone = getNumberTone(performance?.summary?.absoluteReturn) + const quoteDayTone = getNumberTone(quoteDayChange) + const quoteStatusTone: MetricTone = quoteErrorMessage + ? 'negative' + : quoteSnapshotsQuery.isFetching + ? 'warning' + : 'neutral' + + return ( + <div className='flex h-full min-h-0 flex-col bg-background'> + <div className='min-h-0 flex-1 overflow-y-auto'> + <div className='space-y-3'> + <section className='overflow-hidden bg-card/30'> + <div className='flex flex-wrap items-center justify-between gap-3 border-border/60 border-b px-3 py-2.5'> + <div className='min-w-0'> + <div className='flex min-w-0 flex-wrap items-center gap-2'> + <h3 className='font-medium text-sm'>Performance</h3> + {selectedWindow ? ( + <Badge + variant='outline' + className='rounded-sm px-1.5 py-0 font-medium font-mono text-[10px]' + > + {selectedWindow} + </Badge> + ) : null} + </div> + <div className='mt-1 truncate text-muted-foreground text-xs'> + {snapshot.account.name ?? snapshot.account.id} + </div> + </div> + + <div + role='tablist' + aria-label='Performance window' + className='flex flex-wrap items-center gap-1' + > + {activeWindows.map((window) => ( + <Button + key={window} + type='button' + role='tab' + aria-selected={selectedWindow === window} + variant={selectedWindow === window ? 'secondary' : 'ghost'} + size='sm' + className={cn( + 'h-7 cursor-pointer rounded-sm px-2 font-mono text-xs', + selectedWindow === window + ? 'border border-border/60 bg-muted text-foreground' + : 'text-muted-foreground' + )} + onClick={() => { + emitPortfolioSnapshotParamsChange({ + params: { selectedWindow: window }, + panelId, + widgetKey, + }) + }} + > + {window} + </Button> + ))} + </div> + </div> + + <div className='p-3'> + {performanceQuery.isLoading && !performance ? ( + <PortfolioLoading size='sm' className='min-h-[310px]' /> + ) : performanceQuery.error ? ( + <PortfolioMessage + message={ + performanceQuery.error instanceof Error + ? performanceQuery.error.message + : 'Failed to load performance history.' + } + /> + ) : performance?.summary ? ( + <div className='space-y-3'> + <MetricGroup> + <MetricTile + label='Return' + value={formatSignedCurrency(performance.summary.absoluteReturn, currency)} + hint={formatPercent(performance.summary.percentReturn)} + tone={performanceTone} + /> + <MetricTile + label='Start' + value={formatCurrency(performance.summary.startEquity, currency)} + /> + <MetricTile + label='Current' + value={formatCurrency(performance.summary.endEquity, currency)} + /> + <MetricTile + label='High' + value={formatCurrency(performance.summary.highEquity, currency)} + tone='positive' + /> + <MetricTile + label='Low' + value={formatCurrency(performance.summary.lowEquity, currency)} + hint={`As of ${formatAsOf(performance.summary.asOf)}`} + /> + </MetricGroup> + <div className='h-[230px] min-h-[210px] rounded-md border border-border/60 bg-background/70 p-2'> + <PortfolioSnapshotPerformanceChart + series={performance.series} + currency={currency} + /> + </div> + </div> + ) : ( + <PortfolioMessage + message={ + performance?.unavailableReason ?? + 'Performance history is unavailable for the selected account.' + } + /> + )} + </div> + </section> + + <section className='overflow-hidden border-border/70 border-t bg-card/30'> + <div className='flex flex-wrap items-start justify-between gap-3 border-border/60 border-b px-3 py-2.5'> + <div className='min-w-0'> + <div className='flex min-w-0 flex-wrap items-center gap-2'> + <h3 className='font-medium text-sm'>Current Summary</h3> + <Badge + variant='outline' + className='rounded-sm px-1.5 py-0 font-medium text-[10px]' + > + {snapshot.account.status ?? 'unknown'} + </Badge> + </div> + <div className='mt-1 truncate text-muted-foreground text-xs'>{accountMetaText}</div> + </div> + <div className='text-right text-muted-foreground text-xs'> + <div className='font-medium text-foreground'> + {snapshot.account.name ?? snapshot.account.id} + </div> + <div>As of {formatAsOf(snapshot.asOf)}</div> + </div> + </div> + + <div className='p-3'> + <MetricGroup> + <MetricTile + label='Portfolio Value' + value={formatCurrency(snapshot.accountSummary.totalPortfolioValue, currency)} + /> + <MetricTile + label='Cash' + value={formatCurrency(snapshot.accountSummary.totalCashValue, currency)} + /> + <MetricTile + label='Holdings' + value={formatCurrency(snapshot.accountSummary.totalHoldingsValue, currency)} + /> + <MetricTile + label='Buying Power' + value={formatCurrency(snapshot.accountSummary.buyingPower, currency)} + /> + <MetricTile + label='Unrealized P&L' + value={formatSignedCurrency(snapshot.accountSummary.totalUnrealizedPnl, currency)} + tone={getNumberTone(snapshot.accountSummary.totalUnrealizedPnl)} + /> + <MetricTile + label='Positions' + value={String(snapshot.positions.length)} + hint={snapshot.account.id} + /> + </MetricGroup> + </div> + <Separator className='my-3 bg-border/60' /> + + <div className='p-3'> + <div className='flex flex-wrap items-end justify-between gap-2'> + <div className='min-w-0'> + <div className='flex min-w-0 flex-wrap items-center gap-2'> + <h3 className='font-medium text-sm'>Market Quotes</h3> + <Badge + variant='outline' + className='rounded-sm px-1.5 py-0 font-medium text-[10px]' + > + {marketProviderName || 'No market provider'} + </Badge> + </div> + <div className='mt-1 truncate text-muted-foreground text-xs'> + Quote-backed intraday estimate + </div> + </div> + <div className={cn('text-right text-xs', metricToneClassName[quoteStatusTone])}> + {quoteStatusText} + </div> + </div> + + <MetricGroup className='mt-2'> + <MetricTile + label='Quote Value' + value={ + quoteErrorMessage + ? 'N/A' + : quoteSummary.quotedPositions > 0 + ? formatCurrency(quoteSummary.quoteValue, currency) + : 'N/A' + } + hint={quoteErrorMessage ?? marketProviderId ?? 'No market provider'} + tone={quoteErrorMessage ? 'negative' : 'neutral'} + /> + <MetricTile + label='Day Change' + value={quoteErrorMessage ? 'N/A' : formatSignedCurrency(quoteDayChange, currency)} + hint={quoteSnapshotsQuery.isFetching ? 'Refreshing' : undefined} + tone={quoteErrorMessage ? 'negative' : quoteDayTone} + /> + <MetricTile + label='Day %' + value={quoteErrorMessage ? 'N/A' : formatPercent(quoteDayPercent)} + tone={quoteErrorMessage ? 'negative' : getNumberTone(quoteDayPercent)} + /> + <MetricTile + label='Quoted Positions' + value={ + quoteErrorMessage + ? `0/${cappedQuotePositions.length}` + : `${quoteSummary.quotedPositions}/${cappedQuotePositions.length}` + } + hint={quotedPositionsHint} + /> + </MetricGroup> + </div> + </section> + </div> + </div> + </div> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.test.tsx b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.test.tsx new file mode 100644 index 000000000..9203047b2 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.test.tsx @@ -0,0 +1,276 @@ +/** + * @vitest-environment jsdom + */ + +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderPortfolioSnapshotHeader } from '@/widgets/widgets/portfolio_snapshot/components/header' + +const mockUseOAuthProviderAvailability = vi.fn() +const mockEmitPortfolioSnapshotParamsChange = vi.fn() +type MockTradingAccountSelectorProps = { + onAccountSelect?: (selection: unknown) => void +} +const mockTradingAccountSelector = vi.fn(({ onAccountSelect }: MockTradingAccountSelectorProps) => ( + <button + type='button' + data-testid='account-selector' + aria-label='Select trading account' + onClick={() => + onAccountSelect?.({ + accountId: 'acct-1', + }) + } + > + account + </button> +)) + +vi.mock('@/hooks/queries/oauth-provider-availability', () => ({ + useOAuthProviderAvailability: (...args: unknown[]) => mockUseOAuthProviderAvailability(...args), +})) + +vi.mock('@/widgets/utils/portfolio-snapshot-params', () => ({ + emitPortfolioSnapshotParamsChange: (...args: unknown[]) => + mockEmitPortfolioSnapshotParamsChange(...args), +})) + +vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ + widgetHeaderButtonGroupClassName: (className?: string) => + ['controls', className].filter(Boolean).join(' '), + widgetHeaderIconButtonClassName: () => 'icon-button', +})) + +vi.mock('@/widgets/widgets/components/market-provider-settings-button', () => ({ + MarketProviderSettingsButton: () => <button type='button'>Market settings</button>, +})) + +vi.mock('@/widgets/widgets/components/market-provider-selector', () => ({ + MarketProviderSelector: ({ + value, + onChange, + }: { + value?: string + onChange?: (providerId: string) => void + }) => ( + <button + type='button' + data-testid='market-provider-selector' + onClick={() => onChange?.('alpaca')} + > + Market provider {value} + </button> + ), +})) + +vi.mock('@/widgets/widgets/components/trading-provider-selector', () => ({ + TradingProviderSelector: ({ + value, + onChange, + }: { + value?: string + onChange?: (providerId: string) => void + }) => ( + <button + type='button' + data-testid='trading-provider-selector' + onClick={() => onChange?.('tradier')} + > + Trading provider {value} + </button> + ), +})) + +vi.mock('@/widgets/widgets/components/trading-account-selector', () => ({ + TradingAccountSelector: (props: MockTradingAccountSelectorProps) => + mockTradingAccountSelector(props), +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children?: ReactNode }) => <>{children}</>, + TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>, + TooltipContent: ({ children }: { children?: ReactNode }) => <>{children}</>, +})) + +const createQueryResult = <T,>(overrides: Partial<T> = {}) => + ({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + ...overrides, + }) as T + +describe('PortfolioSnapshotHeaderControls', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + mockUseOAuthProviderAvailability.mockReturnValue( + createQueryResult({ + data: { + 'alpaca-live': true, + 'alpaca-paper': true, + tradier: true, + }, + }) + ) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + const renderHeader = async ( + params: Record<string, unknown> | null = { + provider: 'alpaca', + accountId: 'acct-1', + selectedWindow: '1D', + } + ) => { + const slots = renderPortfolioSnapshotHeader?.({ + context: { workspaceId: 'workspace-1' } as any, + panelId: 'panel-1', + widget: { + key: 'portfolio_snapshot', + params, + } as any, + }) + + expect(slots).toBeTruthy() + + await act(async () => { + root.render( + <> + {slots?.left} + {slots?.center} + {slots?.right} + </> + ) + }) + } + + it('does not infer a market provider default from trading provider params', async () => { + await renderHeader() + + expect(container.textContent).toContain('Market provider') + expect(mockEmitPortfolioSnapshotParamsChange).not.toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + marketProvider: expect.any(String), + }), + }) + ) + }) + + it('resets provider-scoped selections when the trading provider changes', async () => { + await renderHeader() + + await act(async () => { + container + .querySelector<HTMLButtonElement>('[data-testid="trading-provider-selector"]') + ?.click() + }) + + expect(mockEmitPortfolioSnapshotParamsChange).toHaveBeenCalledWith({ + params: { + provider: 'tradier', + accountId: null, + credentialServiceId: null, + selectedWindow: null, + }, + panelId: 'panel-1', + widgetKey: 'portfolio_snapshot', + }) + }) + + it('updates the account id from account selection', async () => { + await renderHeader() + + await act(async () => { + container.querySelector<HTMLButtonElement>('[data-testid="account-selector"]')?.click() + }) + + expect(mockEmitPortfolioSnapshotParamsChange).toHaveBeenCalledWith({ + params: { + accountId: 'acct-1', + }, + panelId: 'panel-1', + widgetKey: 'portfolio_snapshot', + }) + }) + + it('renders trading provider immediately before the single account selector', async () => { + await renderHeader() + + const providerButton = container.querySelector('[data-testid="trading-provider-selector"]') + const accountButton = container.querySelector('[data-testid="account-selector"]') + + expect(providerButton).toBeTruthy() + expect(accountButton).toBeTruthy() + expect(container.textContent).not.toContain('Provider settings') + + if (!providerButton || !accountButton) { + throw new Error('Expected provider and account selector controls') + } + + expect( + providerButton.compareDocumentPosition(accountButton) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) + + it('emits a runtime refresh timestamp when the refresh button is clicked', async () => { + await renderHeader() + + await act(async () => { + container + .querySelector<HTMLButtonElement>('button[aria-label="Refresh portfolio snapshot"]') + ?.click() + }) + + expect(mockEmitPortfolioSnapshotParamsChange).toHaveBeenCalledWith({ + params: { + runtime: { + refreshAt: expect.any(Number), + }, + }, + panelId: 'panel-1', + widgetKey: 'portfolio_snapshot', + }) + }) + + it('hides trading controls when no trading providers are configured', async () => { + mockUseOAuthProviderAvailability.mockReturnValue( + createQueryResult({ + data: {}, + }) + ) + + await renderHeader() + + expect(container.querySelector('[data-testid="trading-provider-selector"]')).toBeNull() + expect(container.querySelector('[data-testid="account-selector"]')).toBeNull() + }) + + it('requires selecting a trading provider before showing the account selector', async () => { + await renderHeader(null) + + expect(container.querySelector('[data-testid="trading-provider-selector"]')).toBeTruthy() + expect(container.querySelector('[data-testid="account-selector"]')).toBeNull() + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.tsx b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.tsx new file mode 100644 index 000000000..7fb4473a0 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useMemo } from 'react' +import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' +import type { DashboardWidgetDefinition } from '@/widgets/types' +import { emitPortfolioSnapshotParamsChange } from '@/widgets/utils/portfolio-snapshot-params' +import { MarketProviderControls } from '@/widgets/widgets/components/market-provider-controls' +import { TradingProviderControls } from '@/widgets/widgets/components/trading-provider-controls' +import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' +import { WidgetHeaderRefreshButton } from '@/widgets/widgets/components/widget-header-refresh-button' +import { + getPortfolioSnapshotMarketProviderOptions, + getPortfolioSnapshotProviderAvailabilityIds, + getPortfolioSnapshotProviderOptions, + resolvePortfolioSnapshotMarketProviderId, + resolvePortfolioSnapshotProviderId, +} from '@/widgets/widgets/portfolio_snapshot/components/shared' +import type { PortfolioSnapshotWidgetParams } from '@/widgets/widgets/portfolio_snapshot/types' + +type HeaderControlProps = { + workspaceId?: string + panelId?: string + widgetKey: string + params: PortfolioSnapshotWidgetParams | null +} + +export function PortfolioSnapshotHeaderControls({ + workspaceId, + panelId, + widgetKey, + params, +}: HeaderControlProps) { + const providerAvailabilityQuery = useOAuthProviderAvailability( + getPortfolioSnapshotProviderAvailabilityIds() + ) + const providerOptions = useMemo( + () => getPortfolioSnapshotProviderOptions(providerAvailabilityQuery.data), + [providerAvailabilityQuery.data] + ) + const marketProviderOptions = useMemo(() => getPortfolioSnapshotMarketProviderOptions(), []) + const providerId = resolvePortfolioSnapshotProviderId(params, providerOptions) + const marketProviderId = resolvePortfolioSnapshotMarketProviderId(params, marketProviderOptions) + const areProviderOptionsReady = + !providerAvailabilityQuery.isLoading && + !providerAvailabilityQuery.error && + providerOptions.length > 0 + + return ( + <div className={widgetHeaderButtonGroupClassName('min-w-0')}> + <MarketProviderControls + value={marketProviderId} + options={marketProviderOptions} + onChange={(nextProvider) => { + if (!nextProvider || nextProvider === marketProviderId) return + emitPortfolioSnapshotParamsChange({ + params: { + marketProvider: nextProvider, + marketProviderParams: null, + marketAuth: null, + runtime: { refreshAt: Date.now() }, + }, + panelId, + widgetKey, + }) + }} + providerParams={params?.marketProviderParams} + authParams={params?.marketAuth} + workspaceId={workspaceId} + onSettingsSave={({ providerParams, auth }) => { + emitPortfolioSnapshotParamsChange({ + params: { + marketProviderParams: providerParams, + marketAuth: auth, + runtime: { refreshAt: Date.now() }, + }, + panelId, + widgetKey, + }) + }} + /> + + {areProviderOptionsReady ? ( + <TradingProviderControls + workspaceId={workspaceId} + providerId={providerId} + providerOptions={providerOptions} + credentialServiceId={params?.credentialServiceId} + accountId={params?.accountId} + toolName='Portfolio Snapshot' + onProviderChange={(nextProvider) => { + if (!nextProvider || nextProvider === providerId) return + + emitPortfolioSnapshotParamsChange({ + params: { + provider: nextProvider, + credentialServiceId: null, + accountId: null, + selectedWindow: null, + }, + panelId, + widgetKey, + }) + }} + onAccountSelect={({ accountId, credentialServiceId }) => { + emitPortfolioSnapshotParamsChange({ + params: { + accountId, + ...(credentialServiceId ? { credentialServiceId } : {}), + }, + panelId, + widgetKey, + }) + }} + /> + ) : null} + </div> + ) +} + +function PortfolioSnapshotRefreshControl({ panelId, widgetKey, params }: HeaderControlProps) { + const providerId = typeof params?.provider === 'string' ? params.provider.trim() : '' + + return ( + <WidgetHeaderRefreshButton + label='Refresh portfolio snapshot' + disabled={!providerId} + onClick={() => { + if (!providerId) return + emitPortfolioSnapshotParamsChange({ + params: { + runtime: { + refreshAt: Date.now(), + }, + }, + panelId, + widgetKey, + }) + }} + /> + ) +} + +export const renderPortfolioSnapshotHeader: DashboardWidgetDefinition['renderHeader'] = ({ + panelId, + widget, + context, +}) => { + const widgetKey = widget?.key ?? 'portfolio_snapshot' + const params = (widget?.params as PortfolioSnapshotWidgetParams | null | undefined) ?? null + + return { + left: ( + <PortfolioSnapshotHeaderControls + workspaceId={context?.workspaceId} + panelId={panelId} + widgetKey={widgetKey} + params={params} + /> + ), + right: ( + <PortfolioSnapshotRefreshControl panelId={panelId} widgetKey={widgetKey} params={params} /> + ), + } +} diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/performance-chart.tsx b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/performance-chart.tsx new file mode 100644 index 000000000..bf7d02836 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/performance-chart.tsx @@ -0,0 +1,150 @@ +'use client' + +import { useMemo } from 'react' +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart' +import type { UnifiedTradingPortfolioPerformancePoint } from '@/providers/trading/types' + +const chartConfig = { + equity: { + label: 'Equity', + color: 'hsl(var(--primary))', + }, +} satisfies ChartConfig + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) + +const formatCurrency = ( + value: number, + currency: string, + notation?: Intl.NumberFormatOptions['notation'] +) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + notation, + maximumFractionDigits: notation === 'compact' ? 1 : 2, + }).format(value) + +const formatChartDate = (timestamp: string) => { + const parsed = Date.parse(timestamp) + if (!Number.isFinite(parsed)) return null + + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + }).format(new Date(parsed)) +} + +const formatTooltipDate = (timestamp: string) => { + const parsed = Date.parse(timestamp) + if (!Number.isFinite(parsed)) return timestamp + + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(parsed)) +} + +export function PortfolioSnapshotPerformanceChart({ + series, + currency = 'USD', +}: { + series: UnifiedTradingPortfolioPerformancePoint[] + currency?: string +}) { + const chartData = useMemo( + () => + series + .map((point) => { + const label = formatChartDate(point.timestamp) + if (!label || !isFiniteNumber(point.equity)) { + return null + } + + return { + label, + equity: point.equity, + tooltipLabel: formatTooltipDate(point.timestamp), + } + }) + .filter( + ( + point + ): point is { + label: string + equity: number + tooltipLabel: string + } => point !== null + ), + [series] + ) + + if (chartData.length === 0) { + return ( + <div className='flex h-full min-h-[190px] items-center justify-center rounded-md border border-border/60 border-dashed bg-background/60 px-4 text-center text-muted-foreground text-sm'> + No performance points returned for this window. + </div> + ) + } + + return ( + <ChartContainer config={chartConfig} className='h-full min-h-[190px] w-full'> + <AreaChart accessibilityLayer data={chartData} margin={{ top: 12, right: 16, left: 0 }}> + <defs> + <linearGradient id='fillEquity' x1='0' y1='0' x2='0' y2='1'> + <stop offset='5%' stopColor='var(--color-equity)' stopOpacity={0.35} /> + <stop offset='95%' stopColor='var(--color-equity)' stopOpacity={0.03} /> + </linearGradient> + </defs> + <CartesianGrid vertical={false} strokeDasharray='3 3' /> + <XAxis dataKey='label' tickLine={false} axisLine={false} tickMargin={10} minTickGap={24} /> + <YAxis + width={64} + tickLine={false} + axisLine={false} + tickMargin={8} + tickFormatter={(value) => + isFiniteNumber(value) ? formatCurrency(value, currency, 'compact') : String(value) + } + /> + <ChartTooltip + cursor={false} + content={ + <ChartTooltipContent + indicator='line' + labelFormatter={(_, payload) => { + const point = payload?.[0]?.payload as { tooltipLabel?: string } | undefined + return point?.tooltipLabel ?? '' + }} + formatter={(value) => ( + <div className='flex min-w-[8rem] items-center justify-between gap-4'> + <span className='text-muted-foreground'>Equity</span> + <span className='font-medium font-mono text-foreground tabular-nums'> + {isFiniteNumber(value) ? formatCurrency(value, currency) : String(value)} + </span> + </div> + )} + /> + } + /> + <Area + dataKey='equity' + type='natural' + fill='url(#fillEquity)' + fillOpacity={1} + stroke='var(--color-equity)' + strokeWidth={2} + dot={false} + activeDot={{ r: 4 }} + /> + </AreaChart> + </ChartContainer> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/shared.ts b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/shared.ts new file mode 100644 index 000000000..ed8babd7b --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/shared.ts @@ -0,0 +1,44 @@ +import { getTradingPortfolioSupportedWindows } from '@/providers/trading/portfolio' +import type { TradingPortfolioPerformanceWindow } from '@/providers/trading/types' +import { + resolveConfiguredSeriesMarketProviderId, + getSeriesMarketProviderOptions, +} from '@/widgets/widgets/data_chart/options' +import { + getTradingWidgetProviderAvailabilityIds, + getTradingWidgetProviderOptions, + resolveTradingWidgetProviderId, +} from '@/widgets/utils/trading-widget-providers' +import type { PortfolioSnapshotWidgetParams } from '@/widgets/widgets/portfolio_snapshot/types' + +const DEFAULT_PORTFOLIO_SNAPSHOT_PROVIDER_OPTIONS = getTradingWidgetProviderOptions('holdings') + +export const getPortfolioSnapshotProviderAvailabilityIds = () => + getTradingWidgetProviderAvailabilityIds('holdings') + +export const getPortfolioSnapshotProviderOptions = ( + providerAvailability?: Record<string, boolean> +) => getTradingWidgetProviderOptions('holdings', providerAvailability) + +export const resolvePortfolioSnapshotProviderId = ( + params: PortfolioSnapshotWidgetParams | null | undefined, + providerOptions: Array<{ id: string; name: string }> = DEFAULT_PORTFOLIO_SNAPSHOT_PROVIDER_OPTIONS +) => { + return resolveTradingWidgetProviderId(params?.provider, providerOptions) +} + +export const getPortfolioSnapshotSupportedWindows = (providerId: string) => + getTradingPortfolioSupportedWindows(providerId) + +export const getPortfolioSnapshotDefaultWindow = ( + providerId: string +): TradingPortfolioPerformanceWindow | undefined => { + return getPortfolioSnapshotSupportedWindows(providerId)[0] +} + +export const getPortfolioSnapshotMarketProviderOptions = () => getSeriesMarketProviderOptions() + +export const resolvePortfolioSnapshotMarketProviderId = ( + params: PortfolioSnapshotWidgetParams | null | undefined, + options = getPortfolioSnapshotMarketProviderOptions() +) => resolveConfiguredSeriesMarketProviderId(params?.marketProvider, options) diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/index.tsx b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/index.tsx new file mode 100644 index 000000000..c79b2dd32 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/index.tsx @@ -0,0 +1,16 @@ +'use client' + +import { Wallet } from 'lucide-react' +import type { DashboardWidgetDefinition } from '@/widgets/types' +import { PortfolioSnapshotWidgetBody } from '@/widgets/widgets/portfolio_snapshot/components/body' +import { renderPortfolioSnapshotHeader } from '@/widgets/widgets/portfolio_snapshot/components/header' + +export const portfolioSnapshotWidget: DashboardWidgetDefinition = { + key: 'portfolio_snapshot', + title: 'Portfolio Snapshot', + icon: Wallet, + category: 'trading', + description: 'Broker account performance and current account summary.', + component: (props) => <PortfolioSnapshotWidgetBody {...props} />, + renderHeader: renderPortfolioSnapshotHeader, +} diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/types.ts b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/types.ts new file mode 100644 index 000000000..d7c51890f --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/types.ts @@ -0,0 +1,18 @@ +import type { TradingPortfolioPerformanceWindow } from '@/providers/trading/types' + +export interface PortfolioSnapshotWidgetParams { + provider?: string + credentialServiceId?: string + marketProvider?: string + marketProviderParams?: Record<string, unknown> + marketAuth?: { + apiKey?: string + apiSecret?: string + [key: string]: unknown + } + accountId?: string + selectedWindow?: TradingPortfolioPerformanceWindow + runtime?: { + refreshAt?: number + } +} diff --git a/apps/tradinggoose/widgets/widgets/quick_order/components/body.test.tsx b/apps/tradinggoose/widgets/widgets/quick_order/components/body.test.tsx new file mode 100644 index 000000000..9e6cf4c15 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/quick_order/components/body.test.tsx @@ -0,0 +1,629 @@ +/** + * @vitest-environment jsdom + */ + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useListingSelectorStore } from '@/stores/market/selector/store' +import { QuickOrderWidgetBody } from '@/widgets/widgets/quick_order/components/body' + +const mockUseOAuthProviderAvailability = vi.fn() +const mockUseOAuthCredentialsByProviderIds = vi.fn() +const mockUseMarketQuoteSnapshots = vi.fn() +const mockUseTradingAccounts = vi.fn() +const mockUseTradingPortfolioSnapshot = vi.fn() +const mockUseSubmitTradingOrder = vi.fn() +const mockMutate = vi.fn() +const mockReset = vi.fn() + +const stockListing = { + listing_type: 'default', + listing_id: 'AAPL', + base_id: '', + quote_id: '', + base: 'AAPL', + quote: 'USD', + assetClass: 'stock', +} + +const assetlessListing = { + listing_type: 'default', + listing_id: 'MSFT', + base_id: '', + quote_id: '', + base: 'MSFT', + quote: 'USD', +} + +let nextListing: Record<string, unknown> = stockListing + +vi.mock('@/hooks/queries/oauth-provider-availability', () => ({ + useOAuthProviderAvailability: (...args: unknown[]) => mockUseOAuthProviderAvailability(...args), +})) + +vi.mock('@/hooks/queries/oauth-credentials', () => ({ + useOAuthCredentialsByProviderIds: (...args: unknown[]) => + mockUseOAuthCredentialsByProviderIds(...args), +})) + +vi.mock('@/hooks/queries/market-quote-snapshots', () => ({ + useMarketQuoteSnapshots: (...args: unknown[]) => mockUseMarketQuoteSnapshots(...args), +})) + +vi.mock('@/hooks/queries/trading-portfolio', () => ({ + useTradingAccounts: (...args: unknown[]) => mockUseTradingAccounts(...args), + useTradingPortfolioSnapshot: (...args: unknown[]) => mockUseTradingPortfolioSnapshot(...args), + useSubmitTradingOrder: (...args: unknown[]) => mockUseSubmitTradingOrder(...args), +})) + +vi.mock('@/components/ui/select', () => ({ + Select: ({ + value, + disabled, + onValueChange, + children, + }: { + value?: string + disabled?: boolean + onValueChange?: (value: string) => void + children?: ReactNode + }) => ( + <select + value={value ?? ''} + disabled={disabled} + onChange={(event) => onValueChange?.(event.target.value)} + > + {children} + </select> + ), + SelectTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>, + SelectValue: ({ placeholder }: { placeholder?: string }) => ( + <option value=''>{placeholder}</option> + ), + SelectContent: ({ children }: { children?: ReactNode }) => <>{children}</>, + SelectItem: ({ value, children }: { value: string; children?: ReactNode }) => ( + <option value={value}>{children}</option> + ), +})) + +vi.mock('@/components/ui/radio-group', async () => { + const React = await vi.importActual<typeof import('react')>('react') + const RadioContext = React.createContext<{ + value?: string + onValueChange?: (value: string) => void + }>({}) + + return { + RadioGroup: ({ + value, + onValueChange, + children, + }: { + value?: string + onValueChange?: (value: string) => void + children?: ReactNode + }) => ( + <RadioContext.Provider value={{ value, onValueChange }}> + <div>{children}</div> + </RadioContext.Provider> + ), + RadioGroupItem: ({ id, value }: { id?: string; value: string }) => { + const context = React.useContext(RadioContext) + return ( + <input + id={id} + type='radio' + value={value} + checked={context.value === value} + onChange={() => context.onValueChange?.(value)} + /> + ) + }, + } +}) + +vi.mock('@/components/listing-selector/selector/combo', () => ({ + ListingSelector: ({ + instanceId, + providerType, + listingRequired, + className, + onListingChange, + onListingValueChange, + }: { + instanceId: string + providerType: string + listingRequired?: boolean + className?: string + onListingChange: (listing: Record<string, unknown>) => void + onListingValueChange: (value: string) => void + }) => ( + <div + data-testid='listing-selector-surface' + data-instance-id={instanceId} + data-provider-type={providerType} + data-listing-required={listingRequired ? 'true' : 'false'} + data-class-name={className ?? ''} + > + <button + type='button' + data-testid='listing-selector' + onClick={() => onListingChange(nextListing)} + > + AAPL + </button> + <button + type='button' + data-testid='listing-value-selector' + onClick={() => onListingValueChange('AAPL')} + > + Raw AAPL + </button> + </div> + ), +})) + +const queryResult = <T,>(overrides: Partial<T> = {}) => + ({ + data: undefined, + isLoading: false, + error: null, + refetch: vi.fn(), + ...overrides, + }) as T + +const renderBody = async ( + container: HTMLDivElement, + root: Root, + params: Record<string, unknown>, + onWidgetParamsChange = vi.fn() +) => { + await act(async () => { + root.render( + <QuickOrderWidgetBody + context={{ workspaceId: 'workspace-1' } as any} + widget={{ key: 'quick_order' } as any} + panelId='panel-1' + params={params} + onWidgetParamsChange={onWidgetParamsChange} + /> + ) + }) +} + +const setInputValue = async (input: HTMLInputElement | null, value: string) => { + await act(async () => { + if (!input) throw new Error('input missing') + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + valueSetter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +const setSelectValue = async (select: HTMLSelectElement | null, value: string) => { + await act(async () => { + if (!select) throw new Error('select missing') + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, + 'value' + )?.set + valueSetter?.call(select, value) + select.dispatchEvent(new Event('change', { bubbles: true })) + }) +} + +const chooseRadioValue = async (container: HTMLElement, value: string) => { + await act(async () => { + const radio = container.querySelector<HTMLInputElement>(`input[type="radio"][value="${value}"]`) + if (!radio) throw new Error(`radio ${value} missing`) + radio.click() + }) +} + +const findButton = (container: HTMLElement, label: string) => + Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes(label) + ) + +describe('QuickOrderWidgetBody', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + nextListing = stockListing + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + mockUseOAuthProviderAvailability.mockReturnValue( + queryResult({ data: { 'alpaca-live': true, 'alpaca-paper': true } }) + ) + mockUseOAuthCredentialsByProviderIds.mockReturnValue( + queryResult({ + data: { + 'alpaca-live': [{ id: 'cred-1', name: 'Alpaca Live', provider: 'alpaca-live' }], + }, + }) + ) + mockUseTradingAccounts.mockReturnValue( + queryResult({ data: [{ id: 'acct-1', name: 'Paper Account' }] }) + ) + mockUseTradingPortfolioSnapshot.mockReturnValue( + queryResult({ + data: { + asOf: '2026-04-25T12:00:00.000Z', + account: { + id: 'acct-1', + name: 'Paper Account', + type: 'paper', + baseCurrency: 'USD', + }, + cashBalances: [], + positions: [], + orders: [], + accountSummary: { + totalPortfolioValue: 1000, + totalCashValue: 62.77, + buyingPower: 62.77, + }, + }, + }) + ) + mockUseMarketQuoteSnapshots.mockReturnValue( + queryResult({ + data: { + AAPL: { + lastPrice: 12.5, + previousClose: 12, + change: 0.5, + changePercent: 4.16, + }, + }, + }) + ) + mockUseSubmitTradingOrder.mockReturnValue({ + mutate: mockMutate, + reset: mockReset, + isPending: false, + data: undefined, + error: null, + }) + useListingSelectorStore.setState({ instances: {} }) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('renders order body controls and keeps the submit footer pinned as a sibling', async () => { + await renderBody(container, root, { + provider: 'alpaca', + accountId: 'acct-1', + side: 'buy', + }) + + expect(container.querySelector('[data-testid="listing-selector"]')).not.toBeNull() + const footerButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Submit BUY Order') + ) + expect(footerButton?.parentElement?.className).toContain('shrink-0') + expect(footerButton).toBeDisabled() + }) + + it('keeps listing selector state scoped to a stable trading instance and resets on unmount', async () => { + await renderBody(container, root, { + provider: 'alpaca', + accountId: 'acct-1', + side: 'buy', + }) + + const selector = container.querySelector<HTMLElement>( + '[data-testid="listing-selector-surface"]' + ) + expect(selector?.dataset.instanceId).toBe('quick-order-panel-1-quick_order') + expect(selector?.dataset.providerType).toBe('trading') + expect( + useListingSelectorStore.getState().instances['quick-order-panel-1-quick_order']?.providerId + ).toBe('alpaca') + + await act(async () => { + root.unmount() + }) + root = createRoot(container) + + expect( + useListingSelectorStore.getState().instances['quick-order-panel-1-quick_order'] + ).toMatchObject({ + providerId: undefined, + query: '', + results: [], + selectedListingValue: null, + selectedListing: null, + }) + }) + + it('shows disabled order type placeholders before submit-ready listings', async () => { + await renderBody(container, root, { + provider: 'alpaca', + accountId: 'acct-1', + side: 'buy', + }) + + const emptyOrderTypeSelect = Array.from(container.querySelectorAll('select')).find((select) => + select.textContent?.includes('Select listing first') + ) + expect(emptyOrderTypeSelect).toBeDisabled() + + nextListing = assetlessListing + await act(async () => { + container.querySelector<HTMLButtonElement>('[data-testid="listing-selector"]')?.click() + }) + + const assetlessOrderTypeSelect = Array.from(container.querySelectorAll('select')).find( + (select) => select.textContent?.includes('Asset class unavailable') + ) + expect(assetlessOrderTypeSelect).toBeDisabled() + expect(container.textContent).toContain('Resolved listing asset class is required.') + }) + + it('clears unresolved listing values from submit readiness', async () => { + await renderBody(container, root, { + provider: 'alpaca', + accountId: 'acct-1', + side: 'buy', + }) + + await act(async () => { + container.querySelector<HTMLButtonElement>('[data-testid="listing-selector"]')?.click() + }) + await act(async () => { + container.querySelector<HTMLButtonElement>('[data-testid="listing-value-selector"]')?.click() + }) + + const footerButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Submit BUY Order') + ) + expect(container.textContent).not.toContain('Select a listing.') + expect(footerButton).toBeDisabled() + }) + + it('uses configured market data provider settings for quote websocket subscriptions', async () => { + await renderBody(container, root, { + provider: 'alpaca', + marketProvider: 'finnhub', + marketProviderParams: { region: 'US' }, + marketAuth: { apiKey: 'market-key' }, + accountId: 'acct-1', + side: 'buy', + }) + + await act(async () => { + container.querySelector<HTMLButtonElement>('[data-testid="listing-selector"]')?.click() + }) + await act(async () => {}) + + expect(mockUseMarketQuoteSnapshots).toHaveBeenLastCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + provider: 'finnhub', + auth: { apiKey: 'market-key' }, + providerParams: { region: 'US' }, + enabled: true, + }) + ) + expect(mockUseMarketQuoteSnapshots.mock.calls.at(-1)?.[0].items).toEqual([ + expect.objectContaining({ + listing: stockListing, + }), + ]) + }) + + it('does not use trading provider settings for market quote websocket subscriptions', async () => { + await renderBody(container, root, { + provider: 'alpaca', + accountId: 'acct-1', + side: 'buy', + }) + + await act(async () => { + container.querySelector<HTMLButtonElement>('[data-testid="listing-selector"]')?.click() + }) + await act(async () => {}) + + expect(mockUseMarketQuoteSnapshots).toHaveBeenLastCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + provider: undefined, + auth: undefined, + providerParams: undefined, + enabled: false, + }) + ) + }) + + it('clears invalid providers without deleting account params from async lookups', async () => { + const onInvalidProviderChange = vi.fn() + await renderBody( + container, + root, + { + provider: 'missing-provider', + accountId: 'acct-1', + side: 'buy', + }, + onInvalidProviderChange + ) + expect(onInvalidProviderChange).toHaveBeenCalledWith({ side: 'buy' }) + + await act(async () => { + root.unmount() + }) + root = createRoot(container) + + const onIncompleteAccountOptionsChange = vi.fn() + mockUseTradingAccounts.mockReturnValueOnce( + queryResult({ data: [{ id: 'acct-2', name: 'Other Account' }] }) + ) + await renderBody( + container, + root, + { + provider: 'alpaca', + accountId: 'stale-account', + side: 'buy', + }, + onIncompleteAccountOptionsChange + ) + expect(onIncompleteAccountOptionsChange).not.toHaveBeenCalled() + expect(mockUseTradingPortfolioSnapshot).toHaveBeenLastCalledWith({ + workspaceId: 'workspace-1', + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'stale-account', + }) + }) + + it('keeps invalid numeric text from becoming a submit payload', async () => { + await renderBody(container, root, { + provider: 'alpaca', + accountId: 'acct-1', + side: 'buy', + }) + + await act(async () => { + container.querySelector<HTMLButtonElement>('[data-testid="listing-selector"]')?.click() + }) + await act(async () => {}) + + await setInputValue(container.querySelector<HTMLInputElement>('input[placeholder="0"]'), 'abc') + + const footerButton = findButton(container, 'Submit BUY Order') + expect(footerButton).toBeDisabled() + + await act(async () => { + footerButton?.click() + }) + expect(mockMutate).not.toHaveBeenCalled() + }) + + it('rejects Alpaca notional trailing stop orders before submit', async () => { + await renderBody(container, root, { + provider: 'alpaca', + accountId: 'acct-1', + side: 'buy', + }) + + await act(async () => { + container.querySelector<HTMLButtonElement>('[data-testid="listing-selector"]')?.click() + }) + await act(async () => {}) + + await chooseRadioValue(container, 'notional') + await setInputValue( + container.querySelector<HTMLInputElement>('input[placeholder="0.00"]'), + '100' + ) + + const orderTypeSelect = Array.from(container.querySelectorAll('select')).find((select) => + select.textContent?.includes('Trailing Stop') + ) + await setSelectValue(orderTypeSelect ?? null, 'trailing_stop') + + const footerButton = findButton(container, 'Submit BUY Order') + expect(footerButton).toBeDisabled() + + await act(async () => { + footerButton?.click() + }) + expect(mockMutate).not.toHaveBeenCalled() + }) + + it('submits only the quick order route payload fields', async () => { + await renderBody(container, root, { + provider: 'alpaca', + marketProvider: 'finnhub', + marketProviderParams: { region: 'US' }, + marketAuth: { apiKey: 'market-key' }, + accountId: 'acct-1', + side: 'buy', + }) + + await act(async () => { + container.querySelector<HTMLButtonElement>('[data-testid="listing-selector"]')?.click() + }) + await act(async () => {}) + + await setInputValue(container.querySelector<HTMLInputElement>('input[placeholder="0"]'), '2') + await act(async () => {}) + + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Submit BUY Order')) + ?.click() + }) + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'acct-1', + side: 'buy', + listing: stockListing, + orderType: 'market', + timeInForce: 'day', + orderSizingMode: 'quantity', + quantity: 2, + }) + ) + expect(mockMutate.mock.calls[0][0]).not.toHaveProperty('orderClass') + expect(mockMutate.mock.calls[0][0]).not.toHaveProperty('credentialId') + expect(mockMutate.mock.calls[0][0]).not.toHaveProperty('environment') + expect(mockMutate.mock.calls[0][0]).not.toHaveProperty('providerParams') + expect(mockMutate.mock.calls[0][0]).not.toHaveProperty('marketProvider') + expect(mockMutate.mock.calls[0][0]).not.toHaveProperty('marketProviderParams') + expect(mockMutate.mock.calls[0][0]).not.toHaveProperty('marketAuth') + }) + + it('renders success feedback with destination provider and account details', async () => { + mockUseSubmitTradingOrder.mockReturnValue({ + mutate: mockMutate, + reset: mockReset, + isPending: false, + data: { + provider: 'alpaca', + accountId: 'acct-1', + message: 'Order accepted', + order: { + id: 'order-1', + status: 'submitted', + symbol: 'AAPL', + side: 'buy', + submittedAt: '2026-04-25T12:00:00.000Z', + raw: {}, + }, + }, + error: null, + }) + + await renderBody(container, root, { + provider: 'alpaca', + accountId: 'acct-1', + side: 'buy', + }) + + expect(container.textContent).toContain('Order order-1') + expect(container.textContent).toContain('alpaca / acct-1') + expect(container.textContent).toContain('Order accepted') + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx b/apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx new file mode 100644 index 000000000..afb45073f --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx @@ -0,0 +1,865 @@ +'use client' + +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { ListingSelector } from '@/components/listing-selector/selector/combo' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { LoadingAgent } from '@/components/ui/loading-agent' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { getListingIdentityKey, type ListingOption } from '@/lib/listing/identity' +import type { QuickOrderSubmitRequest } from '@/app/api/providers/trading/order/types' +import { useMarketQuoteSnapshots } from '@/hooks/queries/market-quote-snapshots' +import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' +import { + useSubmitTradingOrder, + useTradingAccounts, + useTradingPortfolioSnapshot, +} from '@/hooks/queries/trading-portfolio' +import { + ALPACA_TRAILING_STOP_TRAIL_VALUE_ERROR, + getAlpacaNotionalOrderTypeError, +} from '@/providers/trading/order-validation' +import { + isTradingOrderListingSupported, + resolveTradingListingAssetClass, +} from '@/providers/trading/utils' +import { useListingSelectorStore } from '@/stores/market/selector/store' +import type { WidgetComponentProps } from '@/widgets/types' +import { + emitQuickOrderParamsChange, + useQuickOrderParamsPersistence, +} from '@/widgets/utils/quick-order-params' +import { useTradingCredentialServices } from '@/widgets/widgets/components/trading-credential-services' +import { + getQuickOrderDefaultTimeInForce, + getQuickOrderOrderTypeDefinitions, + getQuickOrderProviderAvailabilityIds, + getQuickOrderProviderOptions, + getQuickOrderSizingModeConfig, + getQuickOrderTimeInForceOptions, + normalizeQuickOrderNumber, + type QuickOrderNumberParseResult, + resolveQuickOrderMarketProviderId, + resolveQuickOrderOrderType, + resolveQuickOrderProviderId, +} from '@/widgets/widgets/quick_order/components/shared' +import type { QuickOrderWidgetParams } from '@/widgets/widgets/quick_order/types' + +type QuickOrderBodyParams = QuickOrderWidgetParams | null + +const centerStateClassName = + 'flex h-full min-h-0 items-center justify-center px-4 py-6 text-center text-muted-foreground text-sm' + +function CenterState({ children }: { children: string }) { + return <div className={centerStateClassName}>{children}</div> +} + +const formatCurrency = (value: number | null | undefined, currency = 'USD') => { + if (typeof value !== 'number' || !Number.isFinite(value)) return '$ -' + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(value) +} + +function OrderRow({ label, value }: { label: string; value: string }) { + return ( + <div className='flex items-center justify-between gap-3 text-sm'> + <span className='font-medium text-foreground'>{label}</span> + <span className='font-mono text-muted-foreground tabular-nums'>{value}</span> + </div> + ) +} + +function FieldBlock({ children }: { children: ReactNode }) { + return <div className='space-y-2'>{children}</div> +} + +const getParsedNumberValue = (result: QuickOrderNumberParseResult) => + result.ok ? result.value : undefined + +const isPositiveNumber = (value: number | undefined): value is number => + typeof value === 'number' && Number.isFinite(value) && value > 0 + +const getNumberValidationMessage = ( + label: string, + result: QuickOrderNumberParseResult +): string | null => { + if (!result.ok) return `Enter a valid ${label}.` + return isPositiveNumber(result.value) ? null : `Enter ${label}.` +} + +const getValidationMessage = ({ + providerId, + accountId, + listing, + orderType, + timeInForce, + sizingMode, + quantity, + notional, + limitPrice, + stopPrice, + trailPrice, + trailPercent, + orderTypeMessage, +}: { + providerId?: string + accountId?: string + listing: ListingOption | null + orderType?: string + timeInForce?: string + sizingMode?: 'quantity' | 'notional' + quantity: QuickOrderNumberParseResult + notional: QuickOrderNumberParseResult + limitPrice: QuickOrderNumberParseResult + stopPrice: QuickOrderNumberParseResult + trailPrice: QuickOrderNumberParseResult + trailPercent: QuickOrderNumberParseResult + orderTypeMessage?: string | null +}) => { + if (!providerId || !accountId) return 'Select provider and account.' + if (!listing) return 'Select a listing.' + + const resolvedAssetClass = resolveTradingListingAssetClass(listing) + if (!resolvedAssetClass) return 'Resolved listing asset class is required.' + if (!isTradingOrderListingSupported(providerId, listing)) + return 'Listing is not supported by this provider.' + if (orderTypeMessage) return orderTypeMessage + if (!orderType) return 'Select an order type.' + if (!timeInForce) return 'Select a time in force.' + + if (providerId === 'alpaca' && sizingMode === 'notional') { + const notionalMessage = getNumberValidationMessage('notional amount', notional) + if (notionalMessage) return notionalMessage + const orderTypeError = getAlpacaNotionalOrderTypeError(orderType) + if (orderTypeError) return orderTypeError + if (timeInForce !== 'day') return 'Alpaca notional orders require DAY.' + } else { + const quantityMessage = getNumberValidationMessage('quantity', quantity) + if (quantityMessage) return quantityMessage + } + + if (orderType === 'trailing_stop') { + if (!trailPrice.ok) return 'Enter a valid trail price.' + if (!trailPercent.ok) return 'Enter a valid trail percent.' + const hasTrailPrice = isPositiveNumber(trailPrice.value) + const hasTrailPercent = isPositiveNumber(trailPercent.value) + if ((hasTrailPrice && hasTrailPercent) || (!hasTrailPrice && !hasTrailPercent)) { + return ALPACA_TRAILING_STOP_TRAIL_VALUE_ERROR + } + return null + } + + if (orderType === 'limit' || orderType === 'stop_limit') { + const limitPriceMessage = getNumberValidationMessage('limit price', limitPrice) + if (limitPriceMessage) return limitPriceMessage + } + if (orderType === 'stop' || orderType === 'stop_limit') { + const stopPriceMessage = getNumberValidationMessage('stop price', stopPrice) + if (stopPriceMessage) return stopPriceMessage + } + + return null +} + +export function QuickOrderWidgetBody({ + context, + panelId, + widget, + params, + onWidgetParamsChange, +}: WidgetComponentProps) { + const workspaceId = context?.workspaceId ?? null + const quickOrderParams = (params as QuickOrderBodyParams) ?? null + const widgetKey = widget?.key ?? 'quick_order' + const side = quickOrderParams?.side === 'sell' ? 'sell' : 'buy' + + useQuickOrderParamsPersistence({ + onWidgetParamsChange, + panelId, + widget, + params, + }) + + const listingInstanceId = `quick-order-${panelId ?? 'panel'}-${widgetKey}` + const updateListingSelector = useListingSelectorStore((state) => state.updateInstance) + const resetListingSelector = useListingSelectorStore((state) => state.resetInstance) + const previousProviderRef = useRef<string | undefined>(undefined) + const submitOrder = useSubmitTradingOrder() + const resetSubmitOrder = submitOrder.reset + + const [listing, setListing] = useState<ListingOption | null>(null) + const [quantityInput, setQuantityInput] = useState('') + const [notionalInput, setNotionalInput] = useState('') + const [limitPriceInput, setLimitPriceInput] = useState('') + const [stopPriceInput, setStopPriceInput] = useState('') + const [trailPriceInput, setTrailPriceInput] = useState('') + const [trailPercentInput, setTrailPercentInput] = useState('') + const [sizingMode, setSizingMode] = useState<'quantity' | 'notional' | undefined>(undefined) + const [orderType, setOrderType] = useState('') + const [timeInForce, setTimeInForce] = useState('') + + const providerAvailabilityQuery = useOAuthProviderAvailability( + getQuickOrderProviderAvailabilityIds() + ) + const providerOptions = useMemo( + () => getQuickOrderProviderOptions(providerAvailabilityQuery.data), + [providerAvailabilityQuery.data] + ) + const providerId = resolveQuickOrderProviderId( + quickOrderParams?.provider, + providerAvailabilityQuery.data + ) + const hasSelectedProvider = Boolean(providerId) + const areProviderOptionsReady = + !providerAvailabilityQuery.isLoading && + !providerAvailabilityQuery.error && + providerOptions.length > 0 + const credentialServices = useTradingCredentialServices({ + providerId, + credentialServiceId: quickOrderParams?.credentialServiceId, + enabled: areProviderOptionsReady && hasSelectedProvider, + }) + const activeCredentialServiceId = credentialServices.activeServiceId + const accountsQuery = useTradingAccounts({ + workspaceId: workspaceId ?? undefined, + provider: hasSelectedProvider && areProviderOptionsReady ? providerId : undefined, + credentialServiceId: activeCredentialServiceId, + enabled: Boolean(activeCredentialServiceId), + }) + const accounts = accountsQuery.data ?? [] + const singleAccount = accounts.length === 1 ? (accounts[0] ?? null) : null + const selectedAccount = + quickOrderParams?.accountId && !accountsQuery.isLoading && !accountsQuery.error + ? (accounts.find((account) => account.id === quickOrderParams.accountId) ?? null) + : !quickOrderParams?.accountId + ? singleAccount + : null + const activeAccountId = activeCredentialServiceId + ? (quickOrderParams?.accountId ?? singleAccount?.id) + : undefined + const accountSnapshotQuery = useTradingPortfolioSnapshot({ + workspaceId: workspaceId ?? undefined, + provider: hasSelectedProvider && areProviderOptionsReady ? providerId : undefined, + credentialServiceId: activeCredentialServiceId, + accountId: activeAccountId, + }) + const submitResetProviderKey = [ + quickOrderParams?.provider ?? providerId, + activeCredentialServiceId ?? '', + ].join(':') + + const sizingModeConfig = useMemo( + () => (providerId ? getQuickOrderSizingModeConfig(providerId) : { options: [] }), + [providerId] + ) + const sizingOptions = sizingModeConfig.options + const defaultSizingMode = sizingModeConfig.defaultMode + const selectedSizingMode = + sizingOptions.length > 0 + ? sizingMode && sizingOptions.includes(sizingMode) + ? sizingMode + : defaultSizingMode + : undefined + const resolvedAssetClass = listing ? resolveTradingListingAssetClass(listing) : undefined + const isListingSupported = + !providerId || !listing || !resolvedAssetClass + ? false + : isTradingOrderListingSupported(providerId, listing) + const orderTypeDefinitions = useMemo( + () => + providerId && listing && resolvedAssetClass && isListingSupported + ? getQuickOrderOrderTypeDefinitions(providerId, listing) + : [], + [providerId, listing, resolvedAssetClass, isListingSupported] + ) + const defaultOrderTypeResolution = useMemo( + () => + providerId && listing && resolvedAssetClass && isListingSupported + ? resolveQuickOrderOrderType({ providerId, listing }) + : null, + [providerId, listing, resolvedAssetClass, isListingSupported] + ) + const requestedOrderTypeResolution = useMemo( + () => + providerId && listing && resolvedAssetClass && isListingSupported + ? resolveQuickOrderOrderType({ + providerId, + listing, + orderType: orderType || undefined, + }) + : null, + [providerId, listing, resolvedAssetClass, isListingSupported, orderType] + ) + const defaultOrderType = + defaultOrderTypeResolution?.ok === true ? defaultOrderTypeResolution.orderType : '' + const orderTypePlaceholder = !listing + ? 'Select listing first' + : !resolvedAssetClass + ? 'Asset class unavailable' + : !isListingSupported + ? 'Listing unsupported' + : 'No supported types' + const orderTypeMessage = + listing && !resolvedAssetClass + ? 'Resolved listing asset class is required.' + : listing && resolvedAssetClass && !isListingSupported + ? 'Listing is not supported by this provider.' + : requestedOrderTypeResolution?.ok === false && + requestedOrderTypeResolution.reason === 'no_supported_order_types' + ? 'No supported order types for this listing.' + : requestedOrderTypeResolution?.ok === false + ? 'Selected order type is not supported for this listing.' + : null + const timeInForceOptions = useMemo( + () => (providerId ? getQuickOrderTimeInForceOptions(providerId) : []), + [providerId] + ) + const defaultTimeInForce = providerId ? getQuickOrderDefaultTimeInForce(providerId) : undefined + const marketProviderId = resolveQuickOrderMarketProviderId(quickOrderParams) + const quoteItems = useMemo( + () => + listing + ? [ + { + key: getListingIdentityKey(listing), + listing, + }, + ] + : [], + [listing] + ) + const quoteSnapshotsQuery = useMarketQuoteSnapshots({ + workspaceId: workspaceId ?? undefined, + provider: marketProviderId || undefined, + items: quoteItems, + auth: quickOrderParams?.marketAuth, + providerParams: quickOrderParams?.marketProviderParams, + enabled: Boolean(workspaceId && marketProviderId && quoteItems.length > 0), + }) + + const quantity = normalizeQuickOrderNumber(quantityInput) + const notional = normalizeQuickOrderNumber(notionalInput) + const limitPrice = normalizeQuickOrderNumber(limitPriceInput) + const stopPrice = normalizeQuickOrderNumber(stopPriceInput) + const trailPrice = normalizeQuickOrderNumber(trailPriceInput) + const trailPercent = normalizeQuickOrderNumber(trailPercentInput) + const parsedQuantity = getParsedNumberValue(quantity) + const parsedNotional = getParsedNumberValue(notional) + const parsedLimitPrice = getParsedNumberValue(limitPrice) + const parsedStopPrice = getParsedNumberValue(stopPrice) + const parsedTrailPrice = getParsedNumberValue(trailPrice) + const parsedTrailPercent = getParsedNumberValue(trailPercent) + const quoteKey = quoteItems[0]?.key + const selectedQuote = quoteKey ? quoteSnapshotsQuery.data?.[quoteKey] : undefined + const marketPrice = + typeof selectedQuote?.lastPrice === 'number' && Number.isFinite(selectedQuote.lastPrice) + ? selectedQuote.lastPrice + : undefined + const accountSnapshot = accountSnapshotQuery.data + const accountCurrency = + accountSnapshot?.account.baseCurrency ?? selectedAccount?.baseCurrency ?? 'USD' + const cashBuyingPower = + typeof accountSnapshot?.accountSummary.buyingPower === 'number' + ? accountSnapshot.accountSummary.buyingPower + : accountSnapshot?.accountSummary.totalCashValue + const estimatedReferencePrice = parsedLimitPrice ?? parsedStopPrice ?? marketPrice + const estimatedOrderValue = + selectedSizingMode === 'notional' + ? parsedNotional + : parsedQuantity && estimatedReferencePrice + ? parsedQuantity * estimatedReferencePrice + : undefined + const validationMessage = getValidationMessage({ + providerId, + accountId: activeAccountId, + listing, + orderType, + timeInForce, + sizingMode: selectedSizingMode, + quantity, + notional, + limitPrice, + stopPrice, + trailPrice, + trailPercent, + orderTypeMessage, + }) + + useEffect(() => { + if (!areProviderOptionsReady || !quickOrderParams?.provider || providerId) return + emitQuickOrderParamsChange({ + params: { + provider: null, + credentialServiceId: null, + accountId: null, + }, + panelId, + widgetKey, + }) + }, [areProviderOptionsReady, panelId, providerId, quickOrderParams?.provider, widgetKey]) + + useEffect(() => { + if (accountsQuery.isLoading || accountsQuery.error) return + + if (!quickOrderParams?.accountId && accounts.length === 1 && accounts[0]) { + emitQuickOrderParamsChange({ + params: { + accountId: accounts[0].id, + credentialServiceId: activeCredentialServiceId, + }, + panelId, + widgetKey, + }) + } + }, [ + accounts, + accountsQuery.error, + accountsQuery.isLoading, + activeCredentialServiceId, + panelId, + quickOrderParams?.accountId, + widgetKey, + ]) + + useEffect(() => { + if (previousProviderRef.current === providerId) return + previousProviderRef.current = providerId + setListing(null) + setQuantityInput('') + setNotionalInput('') + setLimitPriceInput('') + setStopPriceInput('') + setTrailPriceInput('') + setTrailPercentInput('') + setOrderType('') + setTimeInForce('') + setSizingMode(undefined) + resetSubmitOrder() + updateListingSelector(listingInstanceId, { + providerId, + query: '', + results: [], + isLoading: false, + error: undefined, + selectedListingValue: null, + selectedListing: null, + }) + }, [listingInstanceId, providerId, resetSubmitOrder, updateListingSelector]) + + useEffect(() => { + if (sizingOptions.length === 0) { + if (sizingMode) setSizingMode(undefined) + return + } + if (!sizingMode || !sizingOptions.includes(sizingMode)) { + setSizingMode(defaultSizingMode) + } + }, [defaultSizingMode, sizingMode, sizingOptions]) + + useEffect(() => { + if (!listing || !resolvedAssetClass || !isListingSupported || !defaultOrderType) { + if (orderType) setOrderType('') + return + } + if (!orderType || requestedOrderTypeResolution?.ok === false) { + setOrderType(defaultOrderType) + } + }, [ + defaultOrderType, + isListingSupported, + listing, + orderType, + requestedOrderTypeResolution?.ok, + resolvedAssetClass, + ]) + + useEffect(() => { + if (!defaultTimeInForce) { + if (timeInForce) setTimeInForce('') + return + } + if (!timeInForce || !timeInForceOptions.includes(timeInForce)) { + setTimeInForce(defaultTimeInForce) + } + }, [defaultTimeInForce, timeInForce, timeInForceOptions]) + + useEffect(() => { + const usesLimitPrice = orderType === 'limit' || orderType === 'stop_limit' + const usesStopPrice = orderType === 'stop' || orderType === 'stop_limit' + const usesTrailValue = orderType === 'trailing_stop' + + if (!usesLimitPrice && limitPriceInput) setLimitPriceInput('') + if (!usesStopPrice && stopPriceInput) setStopPriceInput('') + if (!usesTrailValue && trailPriceInput) setTrailPriceInput('') + if (!usesTrailValue && trailPercentInput) setTrailPercentInput('') + }, [limitPriceInput, orderType, stopPriceInput, trailPercentInput, trailPriceInput]) + + useEffect(() => { + resetSubmitOrder() + }, [ + limitPriceInput, + listing, + notionalInput, + orderType, + quickOrderParams?.accountId, + quantityInput, + side, + sizingMode, + stopPriceInput, + resetSubmitOrder, + submitResetProviderKey, + timeInForce, + trailPercentInput, + trailPriceInput, + ]) + + useEffect(() => { + return () => { + resetListingSelector(listingInstanceId) + } + }, [listingInstanceId, resetListingSelector]) + + if (providerAvailabilityQuery.isLoading) { + return ( + <div className={centerStateClassName}> + <LoadingAgent size='md' /> + </div> + ) + } + + if (providerAvailabilityQuery.error) { + return <CenterState>Failed to load trading providers.</CenterState> + } + + if (providerOptions.length === 0) { + return <CenterState>No order-capable trading providers are available.</CenterState> + } + + if (!providerId) { + return <CenterState>Select a trading provider to get started.</CenterState> + } + + if (!activeAccountId) { + if (credentialServices.isLoading) { + return ( + <div className={centerStateClassName}> + <LoadingAgent size='md' /> + </div> + ) + } + + if (!activeCredentialServiceId) { + return <CenterState>Select a broker connection to submit an order.</CenterState> + } + + if (accountsQuery.isLoading) { + return ( + <div className={centerStateClassName}> + <LoadingAgent size='md' /> + </div> + ) + } + + if (accountsQuery.error) { + return <CenterState>Failed to load broker accounts.</CenterState> + } + + if (accounts.length === 0) { + return <CenterState>No broker accounts found for this provider connection.</CenterState> + } + + return <CenterState>Select a broker account to submit an order.</CenterState> + } + + const canSubmit = !validationMessage && !submitOrder.isPending + const order = submitOrder.data?.order + + const handleSubmit = () => { + if ( + validationMessage || + !providerId || + !activeCredentialServiceId || + !activeAccountId || + !listing + ) { + return + } + + const payload: QuickOrderSubmitRequest = { + provider: providerId, + credentialServiceId: activeCredentialServiceId, + accountId: activeAccountId, + listing, + side, + orderType, + timeInForce, + } + + if (providerId === 'alpaca' && selectedSizingMode === 'notional') { + payload.orderSizingMode = 'notional' + if (parsedNotional !== undefined) payload.notional = parsedNotional + } else { + if (selectedSizingMode) payload.orderSizingMode = selectedSizingMode + if (parsedQuantity !== undefined) payload.quantity = parsedQuantity + } + + if ((orderType === 'limit' || orderType === 'stop_limit') && parsedLimitPrice) { + payload.limitPrice = parsedLimitPrice + } + if ((orderType === 'stop' || orderType === 'stop_limit') && parsedStopPrice) { + payload.stopPrice = parsedStopPrice + } + if (orderType === 'trailing_stop') { + if (parsedTrailPrice) payload.trailPrice = parsedTrailPrice + if (parsedTrailPercent) payload.trailPercent = parsedTrailPercent + } + + resetSubmitOrder() + submitOrder.mutate(payload) + } + + return ( + <form + className='flex h-full min-h-0 flex-col bg-background' + onSubmit={(event) => { + event.preventDefault() + handleSubmit() + }} + > + <div className='min-h-0 flex-1 overflow-y-auto px-4 py-4'> + <div className='space-y-5'> + <ListingSelector + instanceId={listingInstanceId} + providerType='trading' + className='w-full' + listingRequired + onListingChange={(nextListing) => { + setListing(nextListing) + setOrderType('') + }} + onListingValueChange={() => { + setListing(null) + setOrderType('') + }} + /> + + <OrderRow label='Market Price' value={formatCurrency(marketPrice, accountCurrency)} /> + + <FieldBlock> + <Label htmlFor='quick-order-size'> + {selectedSizingMode === 'notional' ? 'Notional' : 'Quantity'} + </Label> + <Input + id='quick-order-size' + className='h-9 font-mono' + inputMode='decimal' + value={selectedSizingMode === 'notional' ? notionalInput : quantityInput} + placeholder={selectedSizingMode === 'notional' ? '0.00' : '0'} + onChange={(event) => { + if (selectedSizingMode === 'notional') { + setNotionalInput(event.target.value) + return + } + setQuantityInput(event.target.value) + }} + /> + </FieldBlock> + + <FieldBlock> + <Label htmlFor='quick-order-order-type'>Order Type</Label> + <Select + value={orderType || undefined} + disabled={ + !listing || + !resolvedAssetClass || + !isListingSupported || + orderTypeDefinitions.length === 0 + } + onValueChange={setOrderType} + > + <SelectTrigger id='quick-order-order-type' className='h-9'> + <SelectValue placeholder={orderTypePlaceholder} /> + </SelectTrigger> + <SelectContent> + {orderTypeDefinitions.map((definition) => ( + <SelectItem key={definition.id} value={definition.id}> + {definition.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FieldBlock> + + {sizingOptions.length > 1 ? ( + <FieldBlock> + <Label>Choose how to {side}</Label> + <RadioGroup + className='flex items-center gap-5' + value={selectedSizingMode} + onValueChange={(value) => + setSizingMode(value === 'notional' ? 'notional' : 'quantity') + } + > + {sizingOptions.map((option) => { + const id = `${listingInstanceId}-sizing-${option}` + return ( + <div key={option} className='flex items-center gap-2'> + <RadioGroupItem id={id} value={option} /> + <Label htmlFor={id} className='cursor-pointer text-muted-foreground text-sm'> + {option === 'quantity' ? 'Shares' : 'Dollars'} + </Label> + </div> + ) + })} + </RadioGroup> + </FieldBlock> + ) : null} + + <FieldBlock> + <Label htmlFor='quick-order-time-in-force'>Time in Force</Label> + <Select value={timeInForce || undefined} onValueChange={setTimeInForce}> + <SelectTrigger id='quick-order-time-in-force' className='h-9'> + <SelectValue placeholder='Select time in force' /> + </SelectTrigger> + <SelectContent> + {timeInForceOptions.map((option) => ( + <SelectItem key={option} value={option}> + {option.toUpperCase()} + </SelectItem> + ))} + </SelectContent> + </Select> + </FieldBlock> + + {orderType !== 'trailing_stop' && + (orderType === 'limit' || orderType === 'stop_limit') ? ( + <FieldBlock> + <Label htmlFor='quick-order-limit-price'>Limit Price</Label> + <Input + id='quick-order-limit-price' + className='h-9 font-mono' + inputMode='decimal' + value={limitPriceInput} + placeholder='0.00' + onChange={(event) => setLimitPriceInput(event.target.value)} + /> + </FieldBlock> + ) : null} + + {orderType !== 'trailing_stop' && (orderType === 'stop' || orderType === 'stop_limit') ? ( + <FieldBlock> + <Label htmlFor='quick-order-stop-price'>Stop Price</Label> + <Input + id='quick-order-stop-price' + className='h-9 font-mono' + inputMode='decimal' + value={stopPriceInput} + placeholder='0.00' + onChange={(event) => setStopPriceInput(event.target.value)} + /> + </FieldBlock> + ) : null} + + {orderType === 'trailing_stop' ? ( + <div className='grid grid-cols-2 gap-3'> + <FieldBlock> + <Label htmlFor='quick-order-trail-price'>Trail Price</Label> + <Input + id='quick-order-trail-price' + className='h-9 font-mono' + inputMode='decimal' + value={trailPriceInput} + disabled={Boolean(trailPercentInput)} + placeholder='0.00' + onChange={(event) => { + setTrailPriceInput(event.target.value) + if (event.target.value.trim()) setTrailPercentInput('') + }} + /> + </FieldBlock> + <FieldBlock> + <Label htmlFor='quick-order-trail-percent'>Trail Percent</Label> + <Input + id='quick-order-trail-percent' + className='h-9 font-mono' + inputMode='decimal' + value={trailPercentInput} + disabled={Boolean(trailPriceInput)} + placeholder='0.00' + onChange={(event) => { + setTrailPercentInput(event.target.value) + if (event.target.value.trim()) setTrailPriceInput('') + }} + /> + </FieldBlock> + </div> + ) : null} + + {listing && !resolvedAssetClass ? ( + <div className='rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-amber-300 text-xs'> + Resolved listing asset class is required. + </div> + ) : null} + {listing && resolvedAssetClass && !isListingSupported ? ( + <div className='rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-amber-300 text-xs'> + Listing is not supported by this provider. + </div> + ) : null} + {listing && resolvedAssetClass && isListingSupported && orderTypeMessage ? ( + <div className='rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-amber-300 text-xs'> + {orderTypeMessage} + </div> + ) : null} + </div> + </div> + + <div className='shrink-0 border-border/70 border-t bg-background/95 px-4 py-3'> + <div className='space-y-2 rounded-md border border-border/70 bg-card/30 p-3'> + <OrderRow + label={side === 'sell' ? 'Estimated Proceeds' : 'Estimated Cost'} + value={formatCurrency(estimatedOrderValue, accountCurrency)} + /> + <OrderRow + label='Cash Buying Power' + value={formatCurrency(cashBuyingPower, accountCurrency)} + /> + </div> + {submitOrder.error ? ( + <div className='mb-2 text-destructive text-xs'>{submitOrder.error.message}</div> + ) : order ? ( + <div className='mb-2 text-xs'> + <div className='space-y-0.5 text-muted-foreground'> + <div className='text-foreground'> + {order.id ? `Order ${order.id}` : 'Order submitted'} + {order.status ? ` · ${order.status}` : ''} + </div> + <div> + {[submitOrder.data?.provider, submitOrder.data?.accountId] + .filter(Boolean) + .join(' / ')} + </div> + <div> + {[order.symbol, side.toUpperCase(), order.submittedAt].filter(Boolean).join(' · ')} + </div> + {submitOrder.data?.message ? <div>{submitOrder.data.message}</div> : null} + </div> + </div> + ) : null} + <Button type='submit' className='h-10 w-full' disabled={!canSubmit}> + {submitOrder.isPending ? 'Submitting...' : `Submit ${side.toUpperCase()} Order`} + </Button> + </div> + </form> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/quick_order/components/header.test.tsx b/apps/tradinggoose/widgets/widgets/quick_order/components/header.test.tsx new file mode 100644 index 000000000..c8de1e393 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/quick_order/components/header.test.tsx @@ -0,0 +1,369 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderQuickOrderHeader } from '@/widgets/widgets/quick_order/components/header' + +const mockUseOAuthProviderAvailability = vi.fn() +const mockEmitQuickOrderParamsChange = vi.fn() +type MockMarketProviderControlsProps = { + value?: string | null + workspaceId?: string + providerParams?: Record<string, unknown> + authParams?: Record<string, unknown> + onChange?: (provider: string) => void + onSettingsSave?: (next: { + providerParams?: Record<string, unknown> + auth?: Record<string, unknown> + }) => void +} +const mockMarketProviderControls = vi.fn( + ({ + value, + workspaceId, + providerParams, + authParams, + onChange, + onSettingsSave, + }: MockMarketProviderControlsProps) => ( + <div + data-testid='market-provider-controls' + data-provider={value ?? ''} + data-workspace-id={workspaceId ?? ''} + data-provider-params={JSON.stringify(providerParams ?? null)} + data-auth-params={JSON.stringify(authParams ?? null)} + > + <button + type='button' + data-testid='market-provider-selector' + onClick={() => onChange?.('finnhub')} + > + market provider + </button> + <button + type='button' + data-testid='market-provider-settings' + onClick={() => + onSettingsSave?.({ + providerParams: { region: 'US' }, + auth: { apiKey: 'market-key' }, + }) + } + > + market settings + </button> + </div> + ) +) +type MockTradingAccountSelectorProps = { + onAccountSelect?: (selection: unknown) => void +} +const mockTradingAccountSelector = vi.fn(({ onAccountSelect }: MockTradingAccountSelectorProps) => ( + <button + type='button' + data-testid='account-selector' + onClick={() => + onAccountSelect?.({ + accountId: 'acct-1', + }) + } + > + account + </button> +)) + +vi.mock('@/hooks/queries/oauth-provider-availability', () => ({ + useOAuthProviderAvailability: (...args: unknown[]) => mockUseOAuthProviderAvailability(...args), +})) + +vi.mock('@/widgets/utils/quick-order-params', () => ({ + emitQuickOrderParamsChange: (...args: unknown[]) => mockEmitQuickOrderParamsChange(...args), +})) + +vi.mock('@/widgets/widgets/components/market-provider-controls', () => ({ + MarketProviderControls: (props: MockMarketProviderControlsProps) => + mockMarketProviderControls(props), +})) + +vi.mock('@/widgets/widgets/components/trading-provider-selector', () => ({ + TradingProviderSelector: ({ onChange }: { onChange: (provider: string) => void }) => ( + <button type='button' data-testid='provider-selector' onClick={() => onChange('tradier')}> + provider + </button> + ), +})) + +vi.mock('@/widgets/widgets/components/trading-account-selector', () => ({ + TradingAccountSelector: (props: MockTradingAccountSelectorProps) => + mockTradingAccountSelector(props), +})) + +vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ + widgetHeaderButtonGroupClassName: (className?: string) => + ['controls', className].filter(Boolean).join(' '), +})) + +const queryResult = <T,>(overrides: Partial<T> = {}) => + ({ + data: undefined, + isLoading: false, + error: null, + refetch: vi.fn(), + ...overrides, + }) as T + +const renderHeader = (...args: Parameters<NonNullable<typeof renderQuickOrderHeader>>) => { + if (!renderQuickOrderHeader) throw new Error('quick order header renderer missing') + const header = renderQuickOrderHeader(...args) + if (!header) throw new Error('quick order header output missing') + return header +} + +describe('QuickOrderHeaderControls', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + mockUseOAuthProviderAvailability.mockReturnValue( + queryResult({ data: { 'alpaca-live': true, 'alpaca-paper': true, tradier: true } }) + ) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('renders provider/account controls in left slot and BUY/SELL tabs in center slot', () => { + const header = renderHeader({ + panelId: 'panel-1', + context: { workspaceId: 'workspace-1' } as any, + widget: { + key: 'quick_order', + params: { + provider: 'alpaca', + marketProvider: 'yahoo-finance', + marketProviderParams: { region: 'US' }, + marketAuth: { apiKey: 'market-key' }, + side: 'buy', + }, + } as any, + }) + + act(() => { + root.render( + <> + {header.left} + {header.center} + </> + ) + }) + + expect(container.querySelector('[data-testid="market-provider-controls"]')).not.toBeNull() + expect( + container.querySelector<HTMLElement>('[data-testid="market-provider-controls"]')?.dataset + .workspaceId + ).toBe('workspace-1') + expect( + container.querySelector<HTMLElement>('[data-testid="market-provider-controls"]')?.dataset + .providerParams + ).toBe(JSON.stringify({ region: 'US' })) + expect( + container.querySelector<HTMLElement>('[data-testid="market-provider-controls"]')?.dataset + .authParams + ).toBe(JSON.stringify({ apiKey: 'market-key' })) + expect(container.querySelector('[data-testid="provider-selector"]')).not.toBeNull() + expect(container.querySelector('[data-testid="account-selector"]')).not.toBeNull() + expect(container.textContent).toContain('BUY') + expect(container.textContent).toContain('SELL') + }) + + it('emits scoped provider resets and side changes', () => { + const header = renderHeader({ + panelId: 'panel-1', + widget: { + key: 'quick_order', + params: { provider: 'alpaca', side: 'buy' }, + } as any, + }) + + act(() => { + root.render( + <> + {header.left} + {header.center} + </> + ) + }) + + act(() => { + container + .querySelector<HTMLButtonElement>('[data-testid="market-provider-selector"]') + ?.click() + container.querySelector<HTMLButtonElement>('[data-testid="provider-selector"]')?.click() + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'SELL') + ?.click() + }) + + expect(mockEmitQuickOrderParamsChange).toHaveBeenCalledWith({ + params: { + marketProvider: 'finnhub', + marketProviderParams: null, + marketAuth: null, + }, + panelId: 'panel-1', + widgetKey: 'quick_order', + }) + expect(mockEmitQuickOrderParamsChange).toHaveBeenCalledWith({ + params: { + provider: 'tradier', + accountId: null, + credentialServiceId: null, + }, + panelId: 'panel-1', + widgetKey: 'quick_order', + }) + expect(mockEmitQuickOrderParamsChange).toHaveBeenCalledWith({ + params: { side: 'sell' }, + panelId: 'panel-1', + widgetKey: 'quick_order', + }) + }) + + it('emits scoped market provider settings independently from trading account settings', () => { + const header = renderHeader({ + panelId: 'panel-1', + widget: { + key: 'quick_order', + params: { provider: 'alpaca', marketProvider: 'yahoo-finance', side: 'buy' }, + } as any, + }) + + act(() => { + root.render(<>{header.left}</>) + }) + + act(() => { + container + .querySelector<HTMLButtonElement>('[data-testid="market-provider-settings"]') + ?.click() + }) + + expect(mockEmitQuickOrderParamsChange).toHaveBeenCalledWith({ + params: { + marketProviderParams: { region: 'US' }, + marketAuth: { apiKey: 'market-key' }, + }, + panelId: 'panel-1', + widgetKey: 'quick_order', + }) + }) + + it('does not infer market provider settings from the trading provider', () => { + const header = renderHeader({ + panelId: 'panel-1', + widget: { + key: 'quick_order', + params: { provider: 'alpaca', side: 'buy' }, + } as any, + }) + + act(() => { + root.render(<>{header.left}</>) + }) + + expect(mockMarketProviderControls).toHaveBeenCalledWith( + expect.objectContaining({ + value: '', + providerParams: undefined, + authParams: undefined, + }) + ) + expect(mockEmitQuickOrderParamsChange).not.toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + marketProvider: expect.any(String), + }), + }) + ) + }) + + it('shows the account selector after a trading provider is selected', () => { + const header = renderHeader({ + panelId: 'panel-1', + widget: { + key: 'quick_order', + params: { provider: 'alpaca', side: 'buy' }, + } as any, + }) + + act(() => { + root.render(<>{header.left}</>) + }) + + expect( + container.querySelector<HTMLButtonElement>('[data-testid="account-selector"]') + ).toBeTruthy() + }) + + it('hides account selection before a trading provider is selected', () => { + const header = renderHeader({ + panelId: 'panel-1', + widget: { + key: 'quick_order', + params: { side: 'buy' }, + } as any, + }) + + act(() => { + root.render(<>{header.left}</>) + }) + + expect( + container.querySelector<HTMLButtonElement>('[data-testid="provider-selector"]') + ).toBeTruthy() + expect( + container.querySelector<HTMLButtonElement>('[data-testid="account-selector"]') + ).toBeNull() + }) + + it('updates the account id from account selection', () => { + const header = renderHeader({ + panelId: 'panel-1', + widget: { + key: 'quick_order', + params: { provider: 'alpaca', side: 'buy' }, + } as any, + }) + + act(() => { + root.render(<>{header.left}</>) + }) + + act(() => { + container.querySelector<HTMLButtonElement>('[data-testid="account-selector"]')?.click() + }) + + expect(mockEmitQuickOrderParamsChange).toHaveBeenCalledWith({ + params: { + accountId: 'acct-1', + }, + panelId: 'panel-1', + widgetKey: 'quick_order', + }) + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/quick_order/components/header.tsx b/apps/tradinggoose/widgets/widgets/quick_order/components/header.tsx new file mode 100644 index 000000000..cf1af8c25 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/quick_order/components/header.tsx @@ -0,0 +1,173 @@ +'use client' + +import { useMemo } from 'react' +import { Button } from '@/components/ui/button' +import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' +import type { DashboardWidgetDefinition } from '@/widgets/types' +import { emitQuickOrderParamsChange } from '@/widgets/utils/quick-order-params' +import { MarketProviderControls } from '@/widgets/widgets/components/market-provider-controls' +import { TradingProviderControls } from '@/widgets/widgets/components/trading-provider-controls' +import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' +import { + getQuickOrderMarketProviderOptions, + getQuickOrderProviderAvailabilityIds, + getQuickOrderProviderOptions, + resolveQuickOrderMarketProviderId, + resolveQuickOrderProviderId, +} from '@/widgets/widgets/quick_order/components/shared' +import type { QuickOrderSide, QuickOrderWidgetParams } from '@/widgets/widgets/quick_order/types' + +type HeaderControlProps = { + workspaceId?: string + panelId?: string + widgetKey: string + params: QuickOrderWidgetParams | null +} + +export function QuickOrderHeaderControls({ + workspaceId, + panelId, + widgetKey, + params, +}: HeaderControlProps) { + const providerAvailabilityQuery = useOAuthProviderAvailability( + getQuickOrderProviderAvailabilityIds() + ) + const providerOptions = useMemo( + () => getQuickOrderProviderOptions(providerAvailabilityQuery.data), + [providerAvailabilityQuery.data] + ) + const marketProviderOptions = useMemo(() => getQuickOrderMarketProviderOptions(), []) + const providerId = resolveQuickOrderProviderId(params?.provider, providerAvailabilityQuery.data) + const marketProviderId = resolveQuickOrderMarketProviderId(params, marketProviderOptions) + const areProviderOptionsReady = + !providerAvailabilityQuery.isLoading && + !providerAvailabilityQuery.error && + providerOptions.length > 0 + + return ( + <div className={widgetHeaderButtonGroupClassName('min-w-0')}> + <MarketProviderControls + value={marketProviderId} + options={marketProviderOptions} + onChange={(nextProvider) => { + if (!nextProvider || nextProvider === marketProviderId) return + emitQuickOrderParamsChange({ + params: { + marketProvider: nextProvider, + marketProviderParams: null, + marketAuth: null, + }, + panelId, + widgetKey, + }) + }} + providerParams={params?.marketProviderParams} + authParams={params?.marketAuth} + workspaceId={workspaceId} + onSettingsSave={({ providerParams, auth }) => { + emitQuickOrderParamsChange({ + params: { + marketProviderParams: providerParams, + marketAuth: auth, + }, + panelId, + widgetKey, + }) + }} + /> + + {areProviderOptionsReady ? ( + <TradingProviderControls + workspaceId={workspaceId} + providerId={providerId} + providerOptions={providerOptions} + credentialServiceId={params?.credentialServiceId} + accountId={params?.accountId} + toolName='Quick Order' + onProviderChange={(nextProvider) => { + if (!nextProvider || nextProvider === providerId) return + + emitQuickOrderParamsChange({ + params: { + provider: nextProvider, + credentialServiceId: null, + accountId: null, + }, + panelId, + widgetKey, + }) + }} + onAccountSelect={({ accountId, credentialServiceId }) => { + emitQuickOrderParamsChange({ + params: { + accountId, + ...(credentialServiceId ? { credentialServiceId } : {}), + }, + panelId, + widgetKey, + }) + }} + /> + ) : null} + </div> + ) +} + +function QuickOrderSideTabs({ panelId, widgetKey, params }: HeaderControlProps) { + const side = params?.side === 'sell' ? 'sell' : 'buy' + const sides: Array<{ id: QuickOrderSide; label: string }> = [ + { id: 'buy', label: 'BUY' }, + { id: 'sell', label: 'SELL' }, + ] + + return ( + <div className='flex h-7 items-center gap-1 rounded-sm border border-border/70 bg-card/60 p-1'> + {sides.map((option) => { + const isSelected = option.id === side + + return ( + <Button + key={option.id} + type='button' + variant={isSelected ? 'default' : 'ghost'} + size='sm' + className='h-5 min-w-14 rounded-xs px-3 text-sm' + onClick={() => { + if (option.id === side) return + emitQuickOrderParamsChange({ + params: { side: option.id }, + panelId, + widgetKey, + }) + }} + > + {option.label} + </Button> + ) + })} + </div> + ) +} + +export const renderQuickOrderHeader: DashboardWidgetDefinition['renderHeader'] = ({ + panelId, + widget, + context, +}) => ({ + left: ( + <QuickOrderHeaderControls + workspaceId={context?.workspaceId} + panelId={panelId} + widgetKey={widget?.key ?? 'quick_order'} + params={(widget?.params as QuickOrderWidgetParams | null | undefined) ?? null} + /> + ), + center: ( + <QuickOrderSideTabs + panelId={panelId} + widgetKey={widget?.key ?? 'quick_order'} + params={(widget?.params as QuickOrderWidgetParams | null | undefined) ?? null} + /> + ), +}) diff --git a/apps/tradinggoose/widgets/widgets/quick_order/components/shared.test.ts b/apps/tradinggoose/widgets/widgets/quick_order/components/shared.test.ts new file mode 100644 index 000000000..62002a87e --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/quick_order/components/shared.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import type { ListingResolved } from '@/lib/listing/identity' +import { + getQuickOrderOrderTypeDefinitions, + getQuickOrderSizingModeConfig, + getQuickOrderSizingModeOptions, + normalizeQuickOrderNumber, + resolveQuickOrderOrderType, +} from '@/widgets/widgets/quick_order/components/shared' + +const stockListing: ListingResolved = { + listing_type: 'default' as const, + listing_id: 'AAPL', + base_id: '', + quote_id: '', + base: 'AAPL', + quote: 'USD', + assetClass: 'stock', +} + +const cryptoListing: ListingResolved = { + listing_type: 'crypto' as const, + listing_id: '', + base_id: 'BTC', + quote_id: 'USD', + base: 'BTC', + quote: 'USD', +} + +const assetlessListing: ListingResolved = { + listing_type: 'default' as const, + listing_id: 'MSFT', + base_id: '', + quote_id: '', + base: 'MSFT', + quote: 'USD', +} + +const currencyListing: ListingResolved = { + listing_type: 'currency' as const, + listing_id: '', + base_id: 'EUR', + quote_id: 'USD', + base: 'EUR', + quote: 'USD', +} + +const futureListing = { + listing_type: 'default', + listing_id: 'ES', + base_id: '', + quote_id: '', + base: 'ES', + quote: 'USD', + assetClass: 'future', +} as const + +const indiceListing = { + listing_type: 'default', + listing_id: 'SPX', + base_id: '', + quote_id: '', + base: 'SPX', + quote: 'USD', + assetClass: 'indice', +} as const + +const mutualFundListing = { + listing_type: 'default', + listing_id: 'VTSAX', + base_id: '', + quote_id: '', + base: 'VTSAX', + quote: 'USD', + assetClass: 'mutualfund', +} as const + +describe('quick order shared helpers', () => { + it('exposes sizing mode only for providers with a sizing selector', () => { + expect(getQuickOrderSizingModeOptions('alpaca')).toEqual(['quantity', 'notional']) + expect(getQuickOrderSizingModeConfig('alpaca')).toEqual({ + options: ['quantity', 'notional'], + defaultMode: 'quantity', + }) + expect(getQuickOrderSizingModeOptions('tradier')).toEqual([]) + expect(getQuickOrderSizingModeConfig('tradier')).toEqual({ options: [] }) + }) + + it('uses strict order-type filtering and provider defaults', () => { + expect( + resolveQuickOrderOrderType({ providerId: 'tradier', listing: stockListing }) + ).toMatchObject({ + ok: true, + orderType: 'market', + }) + expect( + getQuickOrderOrderTypeDefinitions('tradier', cryptoListing).map((definition) => definition.id) + ).toEqual([]) + }) + + it('returns explicit failures for unsupported quick order type states', () => { + expect( + resolveQuickOrderOrderType({ + providerId: 'tradier', + listing: assetlessListing, + }) + ).toEqual({ + ok: false, + reason: 'no_supported_order_types', + options: [], + }) + + expect( + resolveQuickOrderOrderType({ + providerId: 'tradier', + listing: stockListing, + orderType: 'trailing_stop', + }) + ).toMatchObject({ + ok: false, + reason: 'unsupported_order_type', + requestedOrderType: 'trailing_stop', + }) + }) + + it('keeps quick-order order types strict across unsupported asset classes', () => { + expect(getQuickOrderOrderTypeDefinitions('tradier', assetlessListing)).toEqual([]) + expect(getQuickOrderOrderTypeDefinitions('tradier', currencyListing)).toEqual([]) + expect(getQuickOrderOrderTypeDefinitions('tradier', futureListing)).toEqual([]) + expect(getQuickOrderOrderTypeDefinitions('tradier', indiceListing)).toEqual([]) + expect(getQuickOrderOrderTypeDefinitions('tradier', mutualFundListing)).toEqual([]) + }) + + it('parses quick order numbers without treating invalid text as empty', () => { + expect(normalizeQuickOrderNumber(' 12.5 ')).toEqual({ ok: true, value: 12.5 }) + expect(normalizeQuickOrderNumber('')).toEqual({ ok: true, value: undefined }) + expect(normalizeQuickOrderNumber(Number.NaN)).toEqual({ + ok: false, + reason: 'invalid_number', + rawValue: Number.NaN, + }) + expect(normalizeQuickOrderNumber('abc')).toEqual({ + ok: false, + reason: 'invalid_number', + rawValue: 'abc', + }) + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/quick_order/components/shared.ts b/apps/tradinggoose/widgets/widgets/quick_order/components/shared.ts new file mode 100644 index 000000000..d83ed3325 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/quick_order/components/shared.ts @@ -0,0 +1,231 @@ +import type { ListingInputValue } from '@/lib/listing/identity' +import { getTradingProvider } from '@/providers/trading' +import { getStrictTradingOrderTypeDefinitions } from '@/providers/trading/order-types' +import type { TradingOrderTypeDefinition } from '@/providers/trading/providers' +import { getTradingProviderParamDefinitions } from '@/providers/trading/providers' +import type { TradingOrderType, TradingProviderId } from '@/providers/trading/types' +import { resolveTradingListingAssetClass } from '@/providers/trading/utils' +import { + getTradingWidgetProviderAvailabilityIds, + getTradingWidgetProviderOptions, + resolveTradingWidgetProviderId, +} from '@/widgets/utils/trading-widget-providers' +import { + resolveConfiguredSeriesMarketProviderId, + getSeriesMarketProviderOptions, +} from '@/widgets/widgets/data_chart/options' +import type { QuickOrderWidgetParams } from '@/widgets/widgets/quick_order/types' + +export const QUICK_ORDER_WIDGET_KEY = 'quick_order' + +export const getQuickOrderProviderAvailabilityIds = () => + getTradingWidgetProviderAvailabilityIds('order') + +export const getQuickOrderProviderOptions = (providerAvailability?: Record<string, boolean>) => { + return getTradingWidgetProviderOptions('order', providerAvailability) +} + +export const resolveQuickOrderProviderId = ( + provider: unknown, + providerAvailability?: Record<string, boolean> +) => { + if (typeof provider !== 'string' || !provider.trim()) return undefined + const providerId = provider.trim() + const options = getQuickOrderProviderOptions(providerAvailability) + return resolveTradingWidgetProviderId(providerId, options) || undefined +} + +export const getQuickOrderMarketProviderOptions = () => getSeriesMarketProviderOptions() + +export const resolveQuickOrderMarketProviderId = ( + params: QuickOrderWidgetParams | null | undefined, + options = getQuickOrderMarketProviderOptions() +) => resolveConfiguredSeriesMarketProviderId(params?.marketProvider, options) + +export type QuickOrderSizingMode = 'quantity' | 'notional' + +export type QuickOrderSizingModeConfig = { + options: QuickOrderSizingMode[] + defaultMode?: QuickOrderSizingMode +} + +export const getQuickOrderSizingModeConfig = (providerId?: string): QuickOrderSizingModeConfig => { + if (!providerId) return { options: [] } + const sizingDefinition = getTradingProviderParamDefinitions(providerId, 'order').find( + (definition) => definition.id === 'orderSizingMode' + ) + const options = + sizingDefinition?.options + ?.map((option) => option.id) + .filter( + (value): value is QuickOrderSizingMode => value === 'quantity' || value === 'notional' + ) ?? [] + const defaultValue = sizingDefinition?.defaultValue + const defaultMode = + typeof defaultValue === 'string' && options.includes(defaultValue as QuickOrderSizingMode) + ? (defaultValue as QuickOrderSizingMode) + : options[0] + + return { options, defaultMode } +} + +export const getQuickOrderSizingModeOptions = (providerId?: string) => + getQuickOrderSizingModeConfig(providerId).options + +export const getQuickOrderTimeInForceOptions = (providerId?: string) => { + if (!providerId) return [] + const provider = getTradingProvider(providerId) + return provider.config.capabilities?.order?.timeInForce ?? [] +} + +export const getQuickOrderDefaultTimeInForce = (providerId?: string) => { + if (!providerId) return undefined + const provider = getTradingProvider(providerId) + const options = getQuickOrderTimeInForceOptions(providerId) + return provider.defaults?.timeInForce ?? options[0] +} + +const quickOrderTypeContext = (providerId?: TradingProviderId, listing?: ListingInputValue) => ({ + listing, + orderClass: providerId === 'tradier' ? 'equity' : undefined, +}) + +export type QuickOrderOrderTypeOption = { + id: string + label: string +} + +export type QuickOrderOrderTypeResolution = + | { + ok: true + definition: TradingOrderTypeDefinition + orderType: TradingOrderType + options: QuickOrderOrderTypeOption[] + } + | { + ok: false + reason: 'no_supported_order_types' | 'unsupported_order_type' + requestedOrderType?: string + options: QuickOrderOrderTypeOption[] + } + +export type QuickOrderNumberParseResult = + | { ok: true; value?: number } + | { ok: false; reason: 'invalid_number'; rawValue: unknown } + +export const getQuickOrderOrderTypeDefinitions = ( + providerId?: TradingProviderId, + listing?: ListingInputValue +) => { + if (!providerId || !listing || !resolveTradingListingAssetClass(listing)) return [] + return getStrictTradingOrderTypeDefinitions( + providerId, + quickOrderTypeContext(providerId, listing) + ) +} + +export const getQuickOrderOrderTypeOptions = ( + providerId?: TradingProviderId, + listing?: ListingInputValue +): QuickOrderOrderTypeOption[] => { + return getQuickOrderOrderTypeDefinitions(providerId, listing).map((definition) => ({ + id: definition.id, + label: definition.label || definition.id, + })) +} + +export const getQuickOrderOrderTypeDefinition = ( + providerId?: TradingProviderId, + orderType?: string, + listing?: ListingInputValue +) => { + const requested = orderType?.trim() + if (!requested) return null + return ( + getQuickOrderOrderTypeDefinitions(providerId, listing).find( + (definition) => definition.id === requested + ) ?? null + ) +} + +export const resolveQuickOrderOrderType = ({ + providerId, + listing, + orderType, +}: { + providerId?: TradingProviderId + listing?: ListingInputValue + orderType?: string +}): QuickOrderOrderTypeResolution => { + if (!providerId) { + return { + ok: false, + reason: 'no_supported_order_types', + options: [], + } + } + + const definitions = getQuickOrderOrderTypeDefinitions(providerId, listing) + const options = definitions.map((definition) => ({ + id: definition.id, + label: definition.label || definition.id, + })) + + if (!definitions.length) { + return { + ok: false, + reason: 'no_supported_order_types', + options, + } + } + + const requested = orderType?.trim() + if (requested) { + const definition = definitions.find((candidate) => candidate.id === requested) + if (!definition) { + return { + ok: false, + reason: 'unsupported_order_type', + requestedOrderType: requested, + options, + } + } + return { + ok: true, + definition, + orderType: definition.id as TradingOrderType, + options, + } + } + + const provider = getTradingProvider(providerId) + const definition = + definitions.find((definition) => definition.id === provider.defaults?.orderType) ?? + definitions[0] + + return { + ok: true, + definition, + orderType: definition.id as TradingOrderType, + options, + } +} + +export const normalizeQuickOrderNumber = (value: unknown): QuickOrderNumberParseResult => { + if (value === null || value === undefined) return { ok: true, value: undefined } + + let parsed: number + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return { ok: true, value: undefined } + parsed = Number(trimmed) + } else if (typeof value === 'number') { + parsed = value + } else { + return { ok: false, reason: 'invalid_number', rawValue: value } + } + + return Number.isFinite(parsed) + ? { ok: true, value: parsed } + : { ok: false, reason: 'invalid_number', rawValue: value } +} diff --git a/apps/tradinggoose/widgets/widgets/quick_order/index.test.ts b/apps/tradinggoose/widgets/widgets/quick_order/index.test.ts new file mode 100644 index 000000000..73742d03a --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/quick_order/index.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { getWidgetDefinition, isValidWidgetKey } from '@/widgets/registry' + +describe('quick order widget registry', () => { + it('registers the quick order widget', () => { + expect(isValidWidgetKey('quick_order')).toBe(true) + expect(getWidgetDefinition('quick_order')).toMatchObject({ + key: 'quick_order', + title: 'Quick Order', + description: 'Manual broker order entry for the selected trading account.', + }) + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/quick_order/index.tsx b/apps/tradinggoose/widgets/widgets/quick_order/index.tsx new file mode 100644 index 000000000..14edce954 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/quick_order/index.tsx @@ -0,0 +1,14 @@ +import { Send } from 'lucide-react' +import type { DashboardWidgetDefinition } from '@/widgets/types' +import { QuickOrderWidgetBody } from '@/widgets/widgets/quick_order/components/body' +import { renderQuickOrderHeader } from '@/widgets/widgets/quick_order/components/header' + +export const quickOrderWidget: DashboardWidgetDefinition = { + key: 'quick_order', + title: 'Quick Order', + icon: Send, + category: 'trading', + description: 'Manual broker order entry for the selected trading account.', + component: QuickOrderWidgetBody, + renderHeader: renderQuickOrderHeader, +} diff --git a/apps/tradinggoose/widgets/widgets/quick_order/types.ts b/apps/tradinggoose/widgets/widgets/quick_order/types.ts new file mode 100644 index 000000000..e60b28b08 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/quick_order/types.ts @@ -0,0 +1,15 @@ +export type QuickOrderSide = 'buy' | 'sell' + +export interface QuickOrderWidgetParams { + provider?: string + credentialServiceId?: string + marketProvider?: string + marketProviderParams?: Record<string, unknown> + marketAuth?: { + apiKey?: string + apiSecret?: string + [key: string]: unknown + } + accountId?: string + side?: QuickOrderSide +} diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector-dropdown.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector-dropdown.tsx deleted file mode 100644 index eed01513e..000000000 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector-dropdown.tsx +++ /dev/null @@ -1,119 +0,0 @@ -'use client' - -import { useEffect, useRef } from 'react' -import { createPortal } from 'react-dom' -import { cn } from '@/lib/utils' -import { MarketListingRow } from '@/components/listing-selector/listing/row' -import type { ListingOption } from '@/lib/listing/identity' - -type DropdownPosition = { - top: number - left: number - width: number -} - -type StockSelectorDropdownProps = { - visible: boolean - results: ListingOption[] - isLoading: boolean - error?: string - highlightedIndex: number - onHighlightChange: (index: number) => void - onSelect: (listing: ListingOption) => void - portalPosition?: DropdownPosition | null - selectorId?: string -} - -export function StockSelectorDropdown({ - visible, - results, - isLoading, - error, - highlightedIndex, - onHighlightChange, - onSelect, - portalPosition, - selectorId, -}: StockSelectorDropdownProps) { - const dropdownRef = useRef<HTMLDivElement>(null) - - useEffect(() => { - if (highlightedIndex < 0 || !dropdownRef.current) return - const target = dropdownRef.current.querySelector( - `[data-option-index="${highlightedIndex}"]` - ) - if (target && target instanceof HTMLElement) { - target.scrollIntoView({ block: 'nearest' }) - } - }, [highlightedIndex]) - - if (!visible) return null - - const content = ( - <div - className={cn( - portalPosition ? 'absolute z-[1000]' : 'absolute left-0 top-full z-[200] mt-1 w-full' - )} - style={ - portalPosition - ? { - top: portalPosition.top, - left: portalPosition.left, - width: portalPosition.width, - } - : undefined - } - data-market-selector - data-market-selector-id={selectorId} - onWheel={(event) => event.stopPropagation()} - > - <div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'> - <div - ref={dropdownRef} - className='allow-scroll max-h-64 overflow-y-auto bg-popover p-1' - style={{ scrollbarWidth: 'thin', overscrollBehavior: 'contain' }} - onMouseLeave={() => onHighlightChange(-1)} - onWheelCapture={(event) => event.stopPropagation()} - onTouchMove={(event) => event.stopPropagation()} - > - {isLoading ? ( - <div className='py-6 text-center text-sm text-muted-foreground'> - Searching... - </div> - ) : results.length === 0 ? ( - <div className='py-6 text-center text-sm text-muted-foreground'> - {error || 'No listings found.'} - </div> - ) : ( - results.map((listing, index) => { - const isHighlighted = index === highlightedIndex - return ( - <div - key={`${listing.listing_type}|${listing.listing_id}|${listing.base_id}|${listing.quote_id}`} - data-option-index={index} - onMouseEnter={() => onHighlightChange(index)} - onMouseDown={(event) => { - event.preventDefault() - onSelect(listing) - }} - className={cn( - 'flex cursor-pointer select-none items-center rounded-sm bg-popover px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground', - isHighlighted && 'bg-accent text-accent-foreground' - )} - > - <MarketListingRow listing={listing} showAssetClass className='w-full' /> - </div> - ) - }) - )} - </div> - </div> - </div> - ) - - if (!portalPosition || typeof document === 'undefined') { - return content - } - - return createPortal(content, document.body) -} diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector.tsx deleted file mode 100644 index 205fc9020..000000000 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector.tsx +++ /dev/null @@ -1,515 +0,0 @@ -'use client' - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ChevronDown } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { formatDisplayText } from '@/components/ui/formatted-text' -import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' -import { useAccessibleReferencePrefixes } from '@/hooks/workflow/use-accessible-reference-prefixes' -import { cn } from '@/lib/utils' -import { MarketListingRow, getListingPrimary } from '@/components/listing-selector/listing/row' -import { - triggerCryptoRankUpdate, - triggerCurrencyRankUpdate, - triggerListingRankUpdate, -} from '@/components/listing-selector/listing/rank-updates' -import { - areListingIdentitiesEqual, - toListingValue, - toListingValueObject, - type ListingIdentity, - type ListingOption, -} from '@/lib/listing/identity' -import { requestListingResolution } from '@/components/listing-selector/selector/resolve-request' -import { useMarketListingSearch } from '@/components/listing-selector/selector/use-listing-search' -import { - createEmptyListingSelectorInstance, - useListingSelectorStore, -} from '@/stores/market/selector/store' -import { StockSelectorDropdown } from '@/widgets/widgets/watchlist/components/stock-selector-dropdown' - -export interface StockSelectorProps { - instanceId: string - blockId?: string - disabled?: boolean - compact?: boolean - className?: string - providerType?: 'market' | 'trading' - activateOnMount?: boolean - onListingChange?: (listing: ListingOption | null) => void - onListingValueChange?: (value: string | null) => void - onListingTagSelect?: (value: string) => void -} - -type DropdownPosition = { - top: number - left: number - width: number -} - -const hasResolvedListingMetadata = (listing?: ListingOption | null): boolean => { - if (!listing) return false - return Boolean(listing.name?.trim() || listing.iconUrl?.trim()) -} - -export function StockSelector({ - instanceId, - blockId, - disabled, - compact = false, - className, - providerType = 'market', - activateOnMount = false, - onListingChange, - onListingValueChange, - onListingTagSelect, -}: StockSelectorProps) { - const ensureInstance = useListingSelectorStore((state) => state.ensureInstance) - const updateInstance = useListingSelectorStore((state) => state.updateInstance) - const instance = useListingSelectorStore((state) => state.instances[instanceId]) - - useEffect(() => { - ensureInstance(instanceId) - }, [ensureInstance, instanceId]) - - const safeInstance = instance ?? createEmptyListingSelectorInstance() - const { - query, - results, - isLoading, - error, - selectedListing, - providerId, - } = safeInstance - - const [open, setOpen] = useState(false) - const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null) - const containerRef = useRef<HTMLDivElement>(null) - const inputRef = useRef<HTMLInputElement>(null) - const [highlightedIndex, setHighlightedIndex] = useState(-1) - const [showTags, setShowTags] = useState(false) - const [cursorPosition, setCursorPosition] = useState(0) - const [variableCommitted, setVariableCommitted] = useState(false) - const hydratedListingRef = useRef<ListingIdentity | null>(null) - const hydrateRequestRef = useRef(0) - const hasActivatedOnMountRef = useRef(false) - const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) - - const isVariableListingInput = useCallback((value: string) => { - const trimmed = value.trim() - if (!trimmed) return false - return trimmed.startsWith('<') - }, []) - - const commitVariableValue = (value: string, source: 'input' | 'tag' = 'input') => { - updateInstance(instanceId, { - query: value, - results: [], - isLoading: false, - error: undefined, - selectedListingValue: null, - selectedListing: null, - }) - setVariableCommitted(true) - if (source === 'tag') { - onListingTagSelect?.(value) - onListingValueChange?.(value) - return - } - onListingValueChange?.(value) - } - - const clearVariableValue = () => { - updateInstance(instanceId, { - query: '', - results: [], - isLoading: false, - error: undefined, - selectedListingValue: null, - selectedListing: null, - }) - setVariableCommitted(false) - onListingValueChange?.(null) - } - - useMarketListingSearch({ - open, - query, - providerId, - providerType, - instanceId, - updateInstance, - isVariableInput: isVariableListingInput, - }) - - const selectedLabel = useMemo(() => { - if (!selectedListing) return '' - const primary = getListingPrimary(selectedListing) - const quote = selectedListing.quote?.trim() - return quote ? `${primary}/${quote}` : primary - }, [selectedListing]) - - const displayValue = open ? query : selectedLabel || query - const showRichOverlay = !open && !!selectedListing - const showTagOverlay = !open && !selectedListing && Boolean(query?.trim().includes('<')) - const showListingDropdown = open && !showTags - const hideInputText = showRichOverlay || showTagOverlay - - const handleSelect = (listing: ListingOption) => { - const primary = getListingPrimary(listing) - const quote = listing.quote?.trim() - const nextLabel = quote ? `${primary}/${quote}` : primary - updateInstance(instanceId, { - selectedListingValue: toListingValue(listing), - selectedListing: listing, - query: nextLabel, - results: [], - error: undefined, - }) - setOpen(false) - setHighlightedIndex(-1) - setShowTags(false) - setVariableCommitted(false) - if (listing.listing_type === 'default') { - triggerListingRankUpdate(listing) - } - if (listing.listing_type === 'crypto' && listing.base_id) { - triggerCryptoRankUpdate(listing.base_id) - } - if (listing.listing_type === 'currency' && listing.base_id) { - triggerCurrencyRankUpdate(listing.base_id) - } - onListingChange?.(listing) - } - - const handleTagSelect = (value: string) => { - const lastOpen = value.lastIndexOf('<') - const lastClose = value.indexOf('>', lastOpen + 1) - const rawTag = - lastOpen >= 0 - ? value.slice(lastOpen + 1, lastClose >= 0 ? lastClose : value.length) - : value - const trimmedTag = rawTag.trim() - const normalizedValue = trimmedTag ? `<${trimmedTag}>` : value - commitVariableValue(normalizedValue, 'tag') - setShowTags(false) - setOpen(false) - setHighlightedIndex(-1) - setCursorPosition(normalizedValue.length) - } - - useEffect(() => { - if (!open) return - const timer = setTimeout(() => { - inputRef.current?.focus() - }, 0) - return () => clearTimeout(timer) - }, [open]) - - useEffect(() => { - if (!activateOnMount || disabled || hasActivatedOnMountRef.current) return - hasActivatedOnMountRef.current = true - const nextQuery = query || selectedLabel - if (nextQuery && query !== nextQuery) { - updateInstance(instanceId, { query: nextQuery }) - } - setCursorPosition(nextQuery.length) - setShowTags(false) - setHighlightedIndex(-1) - setOpen(true) - }, [activateOnMount, disabled, instanceId, query, selectedLabel, updateInstance]) - - useEffect(() => { - const selectedValue = - safeInstance.selectedListingValue ?? safeInstance.selectedListing ?? null - if (!selectedValue) { - hydratedListingRef.current = null - return - } - - const identity = toListingValueObject(selectedValue) - if (!identity) return - - if (safeInstance.selectedListing && hasResolvedListingMetadata(safeInstance.selectedListing)) { - hydratedListingRef.current = identity - return - } - - if (areListingIdentitiesEqual(hydratedListingRef.current, identity)) { - return - } - - hydratedListingRef.current = identity - const requestId = ++hydrateRequestRef.current - let cancelled = false - - requestListingResolution(identity) - .then((resolved) => { - if (cancelled || hydrateRequestRef.current !== requestId || !resolved) return - updateInstance(instanceId, { - selectedListing: resolved, - selectedListingValue: identity, - }) - }) - .catch(() => { }) - - return () => { - cancelled = true - } - }, [safeInstance.selectedListing, safeInstance.selectedListingValue, instanceId, updateInstance]) - - useEffect(() => { - if (open || !selectedLabel || query === selectedLabel) return - updateInstance(instanceId, { query: selectedLabel }) - }, [open, query, selectedLabel, instanceId, updateInstance]) - - useEffect(() => { - setHighlightedIndex((prev) => { - if (prev >= 0 && prev < results.length) { - return prev - } - return -1 - }) - }, [results]) - - useEffect(() => { - if (!showListingDropdown) { - setDropdownPosition(null) - return - } - - const updatePosition = () => { - const container = containerRef.current - if (!container) return - const rect = container.getBoundingClientRect() - setDropdownPosition({ - top: rect.bottom + window.scrollY + 4, - left: rect.left + window.scrollX, - width: rect.width, - }) - } - - updatePosition() - window.addEventListener('resize', updatePosition) - window.addEventListener('scroll', updatePosition, true) - return () => { - window.removeEventListener('resize', updatePosition) - window.removeEventListener('scroll', updatePosition, true) - } - }, [showListingDropdown]) - - return ( - <div - ref={containerRef} - className={cn('relative w-full', className)} - data-market-selector - data-market-selector-id={instanceId} - > - <div className='relative'> - <Input - ref={inputRef} - name={`listing-search-${instanceId}`} - className={cn( - 'w-full pr-10', - compact ? 'h-8 text-sm' : 'h-10', - hideInputText && 'text-transparent caret-transparent placeholder:text-transparent' - )} - placeholder='Select listing' - autoComplete='off' - data-1p-ignore='true' - data-lpignore='true' - data-form-type='other' - value={displayValue} - onChange={(event) => { - if (disabled) return - const nextValue = event.target.value - const newCursorPosition = event.target.selectionStart ?? nextValue.length - setCursorPosition(newCursorPosition) - const tagTrigger = blockId - ? checkTagTrigger(nextValue, newCursorPosition) - : { show: false } - setShowTags(Boolean(blockId) && tagTrigger.show) - - if (!nextValue.trim()) { - clearVariableValue() - setShowTags(false) - return - } - - const isVariable = isVariableListingInput(nextValue) - if (!isVariable && variableCommitted) { - setVariableCommitted(false) - onListingValueChange?.(null) - } - - if (isVariable) { - commitVariableValue(nextValue) - return - } - - const patch: Partial<typeof safeInstance> = { query: nextValue } - if (selectedListing && selectedLabel && nextValue.trim() !== selectedLabel) { - patch.selectedListingValue = null - patch.selectedListing = null - } - updateInstance(instanceId, patch) - }} - onFocus={() => { - if (disabled) return - setOpen(true) - setHighlightedIndex(-1) - const position = inputRef.current?.selectionStart ?? query.length - setCursorPosition(position) - const tagTrigger = blockId ? checkTagTrigger(query, position) : { show: false } - setShowTags(Boolean(blockId) && tagTrigger.show) - }} - onBlur={() => { - if (disabled) return - setTimeout(() => { - const activeElement = document.activeElement - if (activeElement?.closest('[data-market-selector]')) return - if (isVariableListingInput(query)) { - commitVariableValue(query) - } - setOpen(false) - setHighlightedIndex(-1) - if (selectedLabel && query !== selectedLabel) { - updateInstance(instanceId, { query: selectedLabel }) - } - }, 150) - }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - setOpen(false) - setHighlightedIndex(-1) - setShowTags(false) - return - } - - if (showTags) { - return - } - - if (event.key === 'ArrowDown') { - event.preventDefault() - if (!open) { - setOpen(true) - if (results.length > 0) { - setHighlightedIndex(0) - } - } else if (results.length > 0) { - setHighlightedIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0)) - } - } - - if (event.key === 'ArrowUp') { - event.preventDefault() - if (open && results.length > 0) { - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1)) - } - } - - if (event.key === 'Enter' && open && highlightedIndex >= 0) { - event.preventDefault() - const selected = results[highlightedIndex] - if (selected) { - handleSelect(selected) - } - return - } - - if (event.key === 'Enter' && isVariableListingInput(query)) { - event.preventDefault() - commitVariableValue(query) - setOpen(false) - setHighlightedIndex(-1) - } - }} - disabled={disabled} - type='text' - /> - {showRichOverlay ? ( - <div - className={cn( - 'pointer-events-none absolute inset-y-0 left-0 flex items-center w-full', - compact ? 'px-2' : 'px-1' - )} - > - <MarketListingRow - listing={selectedListing} - showAssetClass={!compact} - compact={compact} - className='w-full' - /> - </div> - ) : null} - {showTagOverlay ? ( - <div className='pointer-events-none absolute inset-y-0 left-0 flex items-center px-3 w-full'> - <div className='w-full truncate text-sm'> - {formatDisplayText(query, { - accessiblePrefixes, - highlightAll: !accessiblePrefixes, - })} - </div> - </div> - ) : null} - <Button - variant='ghost' - size='sm' - className='absolute right-1 top-1/2 z-10 h-6 w-6 -translate-y-1/2 bg-transparent p-0' - disabled={disabled} - onMouseDown={(event) => { - event.preventDefault() - if (disabled) return - setOpen((prev) => { - const next = !prev - if (!next) { - setShowTags(false) - } - return next - }) - if (!open) { - inputRef.current?.focus() - } - }} - > - <ChevronDown - className={cn( - 'h-4 w-4 opacity-0 transition-transform', - open && 'rotate-180 opacity-50' - )} - /> - </Button> - </div> - - <StockSelectorDropdown - visible={showListingDropdown} - results={results} - isLoading={isLoading} - error={error} - highlightedIndex={highlightedIndex} - onHighlightChange={setHighlightedIndex} - onSelect={handleSelect} - portalPosition={dropdownPosition} - selectorId={instanceId} - /> - {blockId ? ( - <TagDropdown - visible={showTags} - onSelect={handleTagSelect} - blockId={blockId} - activeSourceBlockId={null} - inputValue={query} - cursorPosition={cursorPosition} - allowVariables={false} - allowContextualTags={false} - requiredOutputShape='listingIdentity' - onClose={() => { - setShowTags(false) - }} - /> - ) : null} - </div> - ) -} diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-body.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-body.test.tsx index 92d575879..a7a389eea 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-body.test.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-body.test.tsx @@ -12,6 +12,10 @@ import { WatchlistWidgetBody } from '@/widgets/widgets/watchlist/components/watc const mockWatchlistTable = vi.fn() const mockRefetchQuotes = vi.fn() +const mockUseMarketQuoteSnapshots = vi.fn((_request: unknown) => ({ + data: {}, + refetch: mockRefetchQuotes, +})) const selectedListing: ListingIdentity = { listing_id: 'BTC', @@ -37,10 +41,11 @@ const watchlist = { createdAt: '2026-03-13T00:00:00.000Z', updatedAt: '2026-03-13T00:00:00.000Z', } +let currentWatchlists = [watchlist] vi.mock('@/hooks/queries/watchlists', () => ({ useWatchlists: () => ({ - data: [watchlist], + data: currentWatchlists, isLoading: false, isFetching: false, error: null, @@ -67,11 +72,8 @@ vi.mock('@/hooks/queries/watchlists', () => ({ }), })) -vi.mock('@/hooks/queries/watchlist-quotes', () => ({ - useWatchlistQuotes: () => ({ - data: {}, - refetch: mockRefetchQuotes, - }), +vi.mock('@/hooks/queries/market-quote-snapshots', () => ({ + useMarketQuoteSnapshots: (request: unknown) => mockUseMarketQuoteSnapshots(request), })) vi.mock('@/widgets/utils/watchlist-params', () => ({ @@ -123,6 +125,7 @@ describe('WatchlistWidgetBody', () => { vi.clearAllMocks() reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true resetPairStore() + currentWatchlists = [watchlist] container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -248,4 +251,79 @@ describe('WatchlistWidgetBody', () => { }) ) }) + + it('uses watchlist item ids as shared quote request keys', async () => { + await act(async () => { + root.render( + <WatchlistWidgetBody + context={{ workspaceId: 'workspace-1' }} + panelId='panel-1' + pairColor='gray' + widget={{ key: 'watchlist', pairColor: 'gray' } as any} + params={{ + watchlistId: 'watchlist-1', + provider: 'alpaca', + auth: { apiKey: '{{ ALPACA_API_KEY }}' }, + providerParams: { feed: 'iex' }, + }} + /> + ) + }) + + expect(mockUseMarketQuoteSnapshots).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + provider: 'alpaca', + items: [ + { + key: 'listing-1', + listing: selectedListing, + }, + ], + auth: { apiKey: '{{ ALPACA_API_KEY }}' }, + providerParams: { feed: 'iex' }, + refreshKey: null, + enabled: true, + }) + }) + + it('uses runtime.refreshAt as the shared quote refresh key without refetching quotes directly', async () => { + await act(async () => { + root.render( + <WatchlistWidgetBody + context={{ workspaceId: 'workspace-1' }} + panelId='panel-1' + pairColor='gray' + widget={{ key: 'watchlist', pairColor: 'gray' } as any} + params={{ + watchlistId: 'watchlist-1', + provider: 'alpaca', + runtime: { refreshAt: 100 }, + }} + /> + ) + }) + + await act(async () => { + root.render( + <WatchlistWidgetBody + context={{ workspaceId: 'workspace-1' }} + panelId='panel-1' + pairColor='gray' + widget={{ key: 'watchlist', pairColor: 'gray' } as any} + params={{ + watchlistId: 'watchlist-1', + provider: 'alpaca', + runtime: { refreshAt: 200 }, + }} + /> + ) + }) + + expect(mockUseMarketQuoteSnapshots.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + refreshKey: 200, + }) + ) + expect(mockRefetchQuotes).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-body.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-body.tsx index 7d9f16d43..439aa982e 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-body.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-body.tsx @@ -1,9 +1,9 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { LoadingAgent } from '@/components/ui/loading-agent' import { areListingIdentitiesEqual, type ListingIdentity } from '@/lib/listing/identity' -import { useWatchlistQuotes } from '@/hooks/queries/watchlist-quotes' +import { useMarketQuoteSnapshots } from '@/hooks/queries/market-quote-snapshots' import { useRemoveWatchlistItem, useRemoveWatchlistSection, @@ -19,7 +19,10 @@ import { emitWatchlistParamsChange, useWatchlistParamsPersistence, } from '@/widgets/utils/watchlist-params' -import { providerOptions } from '@/widgets/widgets/data_chart/options' +import { + providerOptions, + resolveSeriesMarketProviderId, +} from '@/widgets/widgets/data_chart/options' import { resolveSelectedWatchlist, resolveSelectedWatchlistId, @@ -34,9 +37,7 @@ const WatchlistMessage = ({ message }: { message: string }) => ( ) const resolveProviderId = (params: WatchlistWidgetParams | null) => { - const fromParams = typeof params?.provider === 'string' ? params.provider.trim() : '' - if (fromParams) return fromParams - return providerOptions[0]?.id ?? '' + return resolveSeriesMarketProviderId(params?.provider, providerOptions) } export const WatchlistWidgetBody = ({ @@ -69,7 +70,6 @@ export const WatchlistWidgetBody = ({ const removeItemMutation = useRemoveWatchlistItem() const renameSectionMutation = useRenameWatchlistSection() const removeSectionMutation = useRemoveWatchlistSection() - const lastRefreshAtRef = useRef<number | null>(null) useWatchlistParamsPersistence({ onWidgetParamsChange, @@ -121,28 +121,22 @@ export const WatchlistWidgetBody = ({ (selectedWatchlist?.items ?? []) .filter((item) => item.type === 'listing') .map((item) => ({ - itemId: item.id, + key: item.id, listing: item.listing, })), [selectedWatchlist] ) - const { data: quotes = {}, refetch: refetchQuotes } = useWatchlistQuotes({ + const { data: quotes = {} } = useMarketQuoteSnapshots({ workspaceId: workspaceId ?? undefined, provider: providerId || undefined, items: quoteItems, auth: widgetParams?.auth, providerParams: widgetParams?.providerParams, - enabled: Boolean(selectedWatchlist), + refreshKey: refreshAt, + enabled: Boolean(providerId && selectedWatchlist), }) - useEffect(() => { - if (refreshAt == null) return - if (lastRefreshAtRef.current === refreshAt) return - lastRefreshAtRef.current = refreshAt - void refetchQuotes() - }, [refreshAt, refetchQuotes]) - const isMutating = reorderMutation.isPending || updateListingMutation.isPending || @@ -243,23 +237,19 @@ export const WatchlistWidgetBody = ({ } return ( - <div className='flex h-full min-h-0 flex-col'> - <div className='min-h-0 flex-1'> - <WatchlistTable - watchlist={selectedWatchlist} - quotes={quotes} - providerId={providerId} - onUpdateItemListing={handleUpdateItemListing} - onReorderItems={handleReorderItems} - onRemoveItem={handleRemoveItem} - onRenameSection={handleRenameSection} - onRemoveSection={handleRemoveSection} - isMutating={isMutating} - selectedListing={selectedListing} - isLinkedSelection={isLinkedToColorPair} - onSelectListing={handleSelectListing} - /> - </div> - </div> + <WatchlistTable + watchlist={selectedWatchlist} + quotes={quotes} + providerId={providerId} + onUpdateItemListing={handleUpdateItemListing} + onReorderItems={handleReorderItems} + onRemoveItem={handleRemoveItem} + onRenameSection={handleRenameSection} + onRemoveSection={handleRemoveSection} + isMutating={isMutating} + selectedListing={selectedListing} + isLinkedSelection={isLinkedToColorPair} + onSelectListing={handleSelectListing} + /> ) } diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.test.ts b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.test.ts index 68b875cd7..328972579 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.test.ts +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.test.ts @@ -3,7 +3,7 @@ import { resolveNextSectionName, resolveNextWatchlistName, } from '@/widgets/widgets/watchlist/components/watchlist-header-controls' -import { resolveWatchlistProviderCredentialDefinitions } from '@/widgets/widgets/watchlist/components/provider-controls' +import { resolveMarketProviderSettingsDefinitions } from '@/lib/market/market-provider-settings' describe('watchlist header naming helpers', () => { it('resolves the next available watchlist number', () => { @@ -50,13 +50,13 @@ describe('watchlist header naming helpers', () => { ).toBe('Section 2') }) - it('keeps only credential fields for watchlist provider settings', () => { + it('resolves market provider settings fields for watchlist controls', () => { expect( - resolveWatchlistProviderCredentialDefinitions('alpaca').map((definition) => definition.id) - ).toEqual(['apiKey', 'apiSecret']) + resolveMarketProviderSettingsDefinitions('alpaca').map((definition) => definition.id) + ).toEqual(['apiKey', 'apiSecret', 'feed']) expect( - resolveWatchlistProviderCredentialDefinitions('finnhub').map((definition) => definition.id) + resolveMarketProviderSettingsDefinitions('finnhub').map((definition) => definition.id) ).toEqual(['apiKey']) - expect(resolveWatchlistProviderCredentialDefinitions('yahoo-finance')).toEqual([]) + expect(resolveMarketProviderSettingsDefinitions('yahoo-finance')).toEqual([]) }) }) diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.tsx index 3eb77a376..74ba3853f 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.tsx @@ -13,7 +13,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { type ListingIdentity, type ListingOption, toListingValue } from '@/lib/listing/identity' +import { type ListingOption, toListingValue } from '@/lib/listing/identity' import { parseImportedWatchlistFile } from '@/lib/watchlists/import-export' import type { WatchlistRecord } from '@/lib/watchlists/types' import { @@ -30,20 +30,19 @@ import { useListingSelectorStore } from '@/stores/market/selector/store' import type { WidgetInstance } from '@/widgets/layout' import type { DashboardWidgetDefinition } from '@/widgets/types' import { emitWatchlistParamsChange } from '@/widgets/utils/watchlist-params' -import { ListingSelector } from '@/widgets/widgets/components/listing-selector' -import { MarketProviderSelector } from '@/widgets/widgets/components/market-provider-selector' +import { MarketProviderControls } from '@/widgets/widgets/components/market-provider-controls' import { widgetHeaderButtonGroupClassName, widgetHeaderIconButtonClassName, } from '@/widgets/widgets/components/widget-header-control' -import { providerOptions } from '@/widgets/widgets/data_chart/options' +import { WidgetHeaderRefreshButton } from '@/widgets/widgets/components/widget-header-refresh-button' +import { DataChartListingSelector } from '@/widgets/widgets/data_chart/components/listing-control' import { - resolveWatchlistProviderCredentialDefinitions, - WatchlistProviderSettingsButton, -} from '@/widgets/widgets/watchlist/components/provider-controls' + providerOptions, + resolveSeriesMarketProviderId, +} from '@/widgets/widgets/data_chart/options' import { WatchlistListActionsButton } from '@/widgets/widgets/watchlist/components/watchlist-list-actions-button' import { WatchlistListSelector } from '@/widgets/widgets/watchlist/components/watchlist-list-selector' -import { WatchlistRefreshDataButton } from '@/widgets/widgets/watchlist/components/watchlist-refresh-data-button' import { resolveSelectedWatchlist, resolveSelectedWatchlistId, @@ -57,9 +56,7 @@ type WatchlistHeaderControlsSlotProps = { } const resolveProviderId = (params: WatchlistWidgetParams | null | undefined) => { - const fromParams = typeof params?.provider === 'string' ? params.provider.trim() : '' - if (fromParams) return fromParams - return providerOptions[0]?.id ?? '' + return resolveSeriesMarketProviderId(params?.provider, providerOptions) } const toEpochMs = (value?: string | null) => { @@ -144,10 +141,6 @@ export const WatchlistHeaderLeftControls = ({ const widgetKey = widget?.key ?? 'watchlist' const params = resolveWatchlistParams(widget) const providerId = resolveProviderId(params) - const credentialDefinitions = useMemo( - () => resolveWatchlistProviderCredentialDefinitions(providerId), - [providerId] - ) const handleProviderChange = (nextProvider: string) => { if (!nextProvider || nextProvider === providerId) return @@ -160,19 +153,6 @@ export const WatchlistHeaderLeftControls = ({ }) } - const handleRefreshData = () => { - if (!providerId) return - emitWatchlistParamsChange({ - params: { - runtime: { - refreshAt: Date.now(), - }, - }, - panelId, - widgetKey, - }) - } - const handleSaveProviderSettings = ({ providerParams, auth, @@ -194,27 +174,17 @@ export const WatchlistHeaderLeftControls = ({ } return ( - <div className={widgetHeaderButtonGroupClassName('min-w-0')}> - <WatchlistProviderSettingsButton - providerId={providerId} - providerParams={params?.providerParams} - authParams={params?.auth} - definitions={credentialDefinitions} - workspaceId={workspaceId} - onSave={handleSaveProviderSettings} - /> - <MarketProviderSelector - value={providerId} - options={providerOptions} - onChange={handleProviderChange} - disabled={!workspaceId} - /> - - <WatchlistRefreshDataButton - onClick={handleRefreshData} - disabled={!workspaceId || !providerId} - /> - </div> + <MarketProviderControls + className='min-w-0' + value={providerId} + options={providerOptions} + onChange={handleProviderChange} + disabled={!workspaceId} + providerParams={params?.providerParams} + authParams={params?.auth} + workspaceId={workspaceId} + onSettingsSave={handleSaveProviderSettings} + /> ) } @@ -234,12 +204,13 @@ export const WatchlistHeaderCenterControls = ({ ) const ensureSelectorInstance = useListingSelectorStore((state) => state.ensureInstance) const updateSelectorInstance = useListingSelectorStore((state) => state.updateInstance) + const selectorInstance = useListingSelectorStore((state) => state.instances[selectorInstanceId]) const addListingMutation = useAddWatchlistListing() - const [pendingListing, setPendingListing] = useState<ListingIdentity | null>(null) + const pendingListing = selectorInstance?.selectedListingValue ?? null + const selectorProviderId = workspaceId && selectedWatchlist ? providerId : undefined const clearPendingListing = useCallback( (nextProviderId = providerId) => { - setPendingListing(null) updateSelectorInstance(selectorInstanceId, { providerId: nextProviderId || undefined, query: '', @@ -279,7 +250,10 @@ export const WatchlistHeaderCenterControls = ({ }, [clearPendingListing, selectedWatchlist?.id]) const handleListingChange = (listing: ListingOption | null) => { - setPendingListing(toListingValue(listing)) + updateSelectorInstance(selectorInstanceId, { + selectedListingValue: toListingValue(listing), + selectedListing: listing, + }) } const handleAddListing = async () => { @@ -308,13 +282,11 @@ export const WatchlistHeaderCenterControls = ({ return ( <div className={widgetHeaderButtonGroupClassName('min-w-0')}> - <div className='w-full min-w-0 max-w-[240px]'> - <ListingSelector - instanceId={selectorInstanceId} - disabled={!workspaceId || !providerId || !selectedWatchlist} - onListingChange={handleListingChange} - /> - </div> + <DataChartListingSelector + instanceId={selectorInstanceId} + providerId={selectorProviderId} + onListingChange={handleListingChange} + /> <Tooltip> <TooltipTrigger asChild> <span className='inline-flex'> @@ -348,6 +320,7 @@ export const WatchlistHeaderRightControls = ({ const widgetKey = widget?.key ?? 'watchlist' const params = resolveWatchlistParams(widget) + const providerId = resolveProviderId(params) const selectedWatchlistId = resolveSelectedWatchlistId(params) const { watchlists, selectedWatchlist } = useWatchlistSelection(workspaceId, selectedWatchlistId) @@ -511,6 +484,19 @@ export const WatchlistHeaderRightControls = ({ } } + const handleRefreshData = () => { + if (!providerId) return + emitWatchlistParamsChange({ + params: { + runtime: { + refreshAt: Date.now(), + }, + }, + panelId, + widgetKey, + }) + } + const handleDeleteWatchlist = async () => { if (!workspaceId || !selectedWatchlist || selectedWatchlist.isSystem) return const deleted = await handleDeleteWatchlistById(selectedWatchlist.id) @@ -557,6 +543,11 @@ export const WatchlistHeaderRightControls = ({ }} /> + <WidgetHeaderRefreshButton + onClick={handleRefreshData} + disabled={!workspaceId || !providerId} + /> + <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialogContent> <AlertDialogHeader> diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx index e4d5ee280..74bf8c4f6 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx @@ -6,6 +6,7 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react' import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useListingSelectorStore } from '@/stores/market/selector/store' import { WatchlistHeaderCenterControls, WatchlistHeaderRightControls, @@ -141,6 +142,7 @@ describe('watchlist header controls', () => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) + useListingSelectorStore.setState({ instances: {} }) mockUseWatchlists.mockReturnValue({ data: [defaultWatchlist], @@ -159,6 +161,7 @@ describe('watchlist header controls', () => { root.unmount() }) container.remove() + useListingSelectorStore.setState({ instances: {} }) }) it('adds the staged listing from the center header control', async () => { @@ -200,6 +203,24 @@ describe('watchlist header controls', () => { expect(addButton?.hasAttribute('disabled')).toBe(false) + await act(async () => { + useListingSelectorStore + .getState() + .updateInstance('watchlist-header-listing-panel-2-watchlist-widget', { + query: 'ETH', + selectedListingValue: null, + selectedListing: null, + }) + }) + + expect(addButton?.hasAttribute('disabled')).toBe(true) + + await act(async () => { + listingButton?.dispatchEvent(new globalThis.MouseEvent('click', { bubbles: true })) + }) + + expect(addButton?.hasAttribute('disabled')).toBe(false) + await act(async () => { addButton?.dispatchEvent(new globalThis.MouseEvent('click', { bubbles: true })) await Promise.resolve() diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-left-controls.ui.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-left-controls.ui.test.tsx index a8867e1f2..1df444725 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-left-controls.ui.test.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-left-controls.ui.test.tsx @@ -13,31 +13,28 @@ vi.mock('@/widgets/utils/watchlist-params', () => ({ emitWatchlistParamsChange: (...args: unknown[]) => mockEmitWatchlistParamsChange(...args), })) -vi.mock('@/widgets/widgets/components/market-provider-selector', () => ({ - MarketProviderSelector: () => <div>provider-selector</div>, -})) - -vi.mock('@/widgets/widgets/watchlist/components/watchlist-refresh-data-button', () => ({ - WatchlistRefreshDataButton: () => <div>refresh-button</div>, -})) - -vi.mock('@/widgets/widgets/watchlist/components/provider-controls', () => ({ - resolveWatchlistProviderCredentialDefinitions: (providerId?: string) => - providerId === 'alpaca' ? [{ id: 'apiKey' }, { id: 'apiSecret' }] : [], - WatchlistProviderSettingsButton: (props: { - definitions: Array<{ id: string }> - onSave: (next: { +vi.mock('@/widgets/widgets/components/market-provider-controls', () => ({ + MarketProviderControls: (props: { + value?: string + className?: string + onSettingsSave: (next: { auth?: Record<string, unknown> providerParams?: Record<string, unknown> }) => void }) => { - if (props.definitions.length === 0) return null + if (props.value !== 'alpaca') { + return <div className={props.className}>provider-selector</div> + } return ( <button type='button' + className={props.className} onClick={() => - props.onSave({ auth: { apiKey: 'secret' }, providerParams: { feed: 'iex' } }) + props.onSettingsSave({ + auth: { apiKey: '{{ ALPACA_API_KEY }}' }, + providerParams: { feed: 'iex' }, + }) } > provider-settings @@ -91,6 +88,7 @@ describe('WatchlistHeaderLeftControls', () => { }) expect(container.textContent).not.toContain('provider-settings') + expect(container.textContent).toContain('provider-selector') expect(container.firstElementChild?.className).toContain('min-w-0') await act(async () => { @@ -139,7 +137,7 @@ describe('WatchlistHeaderLeftControls', () => { expect(mockEmitWatchlistParamsChange).toHaveBeenCalledWith({ params: { providerParams: { feed: 'iex' }, - auth: { apiKey: 'secret' }, + auth: { apiKey: '{{ ALPACA_API_KEY }}' }, runtime: { refreshAt: expect.any(Number), }, diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.test.tsx similarity index 93% rename from apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector.test.tsx rename to apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.test.tsx index 39fa43de8..371484e72 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector.test.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.test.tsx @@ -9,7 +9,7 @@ import { createEmptyListingSelectorInstance, useListingSelectorStore, } from '@/stores/market/selector/store' -import { StockSelector } from '@/widgets/widgets/watchlist/components/stock-selector' +import { WatchlistListingSelector } from '@/widgets/widgets/watchlist/components/watchlist-listing-selector' const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean @@ -23,7 +23,7 @@ vi.mock('@/components/listing-selector/selector/use-listing-search', () => ({ useMarketListingSearch: vi.fn(), })) -describe('Watchlist StockSelector', () => { +describe('WatchlistListingSelector', () => { let container: HTMLDivElement let root: Root @@ -73,7 +73,7 @@ describe('Watchlist StockSelector', () => { await act(async () => { root.render( - <StockSelector + <WatchlistListingSelector instanceId='test-selector' providerType='market' activateOnMount diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.tsx new file mode 100644 index 000000000..39a6f5bcb --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.tsx @@ -0,0 +1,20 @@ +'use client' + +import type { ListingOption } from '@/lib/listing/identity' +import { ListingSelector } from '@/widgets/widgets/components/listing-selector' + +export interface WatchlistListingSelectorProps { + instanceId: string + blockId?: string + disabled?: boolean + className?: string + providerType?: 'market' | 'trading' + activateOnMount?: boolean + onListingChange?: (listing: ListingOption | null) => void + onListingValueChange?: (value: string | null) => void + onListingTagSelect?: (value: string) => void +} + +export function WatchlistListingSelector(props: WatchlistListingSelectorProps) { + return <ListingSelector {...props} /> +} diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-refresh-data-button.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-refresh-data-button.tsx deleted file mode 100644 index 5f7068c2c..000000000 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-refresh-data-button.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' - -import { RefreshCw } from 'lucide-react' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { widgetHeaderIconButtonClassName } from '@/widgets/widgets/components/widget-header-control' - -type WatchlistRefreshDataButtonProps = { - disabled?: boolean - onClick: () => void -} - -export const WatchlistRefreshDataButton = ({ - disabled = false, - onClick, -}: WatchlistRefreshDataButtonProps) => ( - <Tooltip> - <TooltipTrigger asChild> - <button - type='button' - className={widgetHeaderIconButtonClassName()} - onClick={onClick} - disabled={disabled} - > - <RefreshCw className='h-3.5 w-3.5' /> - <span className='sr-only'>Refresh data</span> - </button> - </TooltipTrigger> - <TooltipContent side='top'>Refresh data</TooltipContent> - </Tooltip> -) diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-table.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-table.tsx index 734205f51..3c52f54e0 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-table.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-table.tsx @@ -45,15 +45,15 @@ import { type ListingOption, toListingValue, } from '@/lib/listing/identity' +import type { MarketQuoteSnapshot } from '@/lib/market/quote-snapshot-contract' import { cn } from '@/lib/utils' import type { WatchlistListingItem, WatchlistRecord, WatchlistSectionItem, } from '@/lib/watchlists/types' -import type { WatchlistQuoteSnapshot } from '@/hooks/queries/watchlist-quotes' import { useListingSelectorStore } from '@/stores/market/selector/store' -import { StockSelector } from '@/widgets/widgets/watchlist/components/stock-selector' +import { WatchlistListingSelector } from '@/widgets/widgets/watchlist/components/watchlist-listing-selector' import { createWatchlistListingSortableId, createWatchlistSectionSortableId, @@ -69,7 +69,7 @@ import { type WatchlistTableProps = { watchlist: WatchlistRecord | null - quotes: Record<string, WatchlistQuoteSnapshot> + quotes: Record<string, MarketQuoteSnapshot> providerId?: string onUpdateItemListing: (itemId: string, listing: ListingIdentity) => Promise<boolean> | boolean onReorderItems: (orderedItemIds: string[]) => Promise<void> @@ -539,7 +539,7 @@ export const WatchlistTable = ({ return ( <div className='relative z-20 flex items-center bg-background'> - <StockSelector + <WatchlistListingSelector instanceId={instanceId} providerType='market' disabled={isMutating} diff --git a/apps/tradinggoose/widgets/widgets/watchlist/index.tsx b/apps/tradinggoose/widgets/widgets/watchlist/index.tsx index 0c800c6d4..1cc64a760 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/index.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/index.tsx @@ -9,7 +9,7 @@ export const watchlistWidget: DashboardWidgetDefinition = { key: 'watchlist', title: 'Watchlist', icon: List, - category: 'list', + category: 'trading', description: 'Manage symbol watchlists with live market columns.', component: (props) => <WatchlistWidgetBody {...props} />, renderHeader: renderWatchlistHeader, diff --git a/apps/tradinggoose/widgets/widgets/watchlist/watchlist-table.sections.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/watchlist-table.sections.test.tsx index 17f9cc668..e26c6a47f 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/watchlist-table.sections.test.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/watchlist-table.sections.test.tsx @@ -23,7 +23,7 @@ const mockResolveListing = vi.fn() const mockEnsureListingSelectorInstance = vi.fn() const mockUpdateListingSelectorInstance = vi.fn() const mockResetListingSelectorInstance = vi.fn() -const mockStockSelectorRender = vi.fn() +const mockWatchlistListingSelectorRender = vi.fn() vi.mock('@/components/listing-selector/listing/row', () => ({ getListingPrimary: (listing: { name?: string; listing_id?: string }) => @@ -41,8 +41,8 @@ vi.mock('@/components/listing-selector/listing/row', () => ({ ), })) -vi.mock('@/widgets/widgets/watchlist/components/stock-selector', () => ({ - StockSelector: ({ +vi.mock('@/widgets/widgets/watchlist/components/watchlist-listing-selector', () => ({ + WatchlistListingSelector: ({ instanceId, activateOnMount, onListingChange, @@ -57,15 +57,15 @@ vi.mock('@/widgets/widgets/watchlist/components/stock-selector', () => ({ name?: string }) => void }) => { - mockStockSelectorRender({ instanceId, activateOnMount }) + mockWatchlistListingSelectorRender({ instanceId, activateOnMount }) return ( - <div data-testid={`stock-selector-${instanceId}`}> - <button type='button' data-testid={`stock-selector-focus-${instanceId}`}> - stock-selector-focus + <div data-testid={`watchlist-listing-selector-${instanceId}`}> + <button type='button' data-testid={`watchlist-listing-selector-focus-${instanceId}`}> + watchlist-listing-selector-focus </button> <button type='button' - data-testid={`stock-selector-select-${instanceId}`} + data-testid={`watchlist-listing-selector-select-${instanceId}`} onClick={() => onListingChange?.({ listing_id: 'eth-id', @@ -76,7 +76,7 @@ vi.mock('@/widgets/widgets/watchlist/components/stock-selector', () => ({ }) } > - stock-selector-select + watchlist-listing-selector-select </button> </div> ) @@ -481,7 +481,9 @@ describe('WatchlistTable section interactions', () => { const confirmButton = container.querySelector('[data-testid="confirm-delete-section"]') await act(async () => { - confirmButton?.dispatchEvent(new globalThis.MouseEvent('click', { bubbles: true, cancelable: true })) + confirmButton?.dispatchEvent( + new globalThis.MouseEvent('click', { bubbles: true, cancelable: true }) + ) }) expect(onRemoveSection).toHaveBeenCalledWith('section-1') @@ -524,7 +526,9 @@ describe('WatchlistTable section interactions', () => { ) await act(async () => { - confirmButton?.dispatchEvent(new globalThis.MouseEvent('click', { bubbles: true, cancelable: true })) + confirmButton?.dispatchEvent( + new globalThis.MouseEvent('click', { bubbles: true, cancelable: true }) + ) }) expect(onRemoveItem).toHaveBeenCalledWith('listing-1') @@ -565,17 +569,17 @@ describe('WatchlistTable section interactions', () => { }) const selector = container.querySelector( - '[data-testid="stock-selector-watchlist-listing-editor-listing-1"]' + '[data-testid="watchlist-listing-selector-watchlist-listing-editor-listing-1"]' ) expect(selector).toBeTruthy() - expect(mockStockSelectorRender).toHaveBeenLastCalledWith({ + expect(mockWatchlistListingSelectorRender).toHaveBeenLastCalledWith({ instanceId: 'watchlist-listing-editor-listing-1', activateOnMount: true, }) const selectButton = container.querySelector( - '[data-testid="stock-selector-select-watchlist-listing-editor-listing-1"]' + '[data-testid="watchlist-listing-selector-select-watchlist-listing-editor-listing-1"]' ) await act(async () => { @@ -624,7 +628,7 @@ describe('WatchlistTable section interactions', () => { }) const selectButton = container.querySelector( - '[data-testid="stock-selector-select-watchlist-listing-editor-listing-1"]' + '[data-testid="watchlist-listing-selector-select-watchlist-listing-editor-listing-1"]' ) await act(async () => { @@ -722,10 +726,10 @@ describe('WatchlistTable section interactions', () => { }) const selector = container.querySelector( - '[data-testid="stock-selector-watchlist-listing-editor-listing-1"]' + '[data-testid="watchlist-listing-selector-watchlist-listing-editor-listing-1"]' ) const focusButton = container.querySelector( - '[data-testid="stock-selector-focus-watchlist-listing-editor-listing-1"]' + '[data-testid="watchlist-listing-selector-focus-watchlist-listing-editor-listing-1"]' ) const editingRow = Array.from(container.querySelectorAll('tr')).find( (row) => @@ -744,7 +748,9 @@ describe('WatchlistTable section interactions', () => { }) expect( - container.querySelector('[data-testid="stock-selector-watchlist-listing-editor-listing-1"]') + container.querySelector( + '[data-testid="watchlist-listing-selector-watchlist-listing-editor-listing-1"]' + ) ).toBeTruthy() expect(onUpdateItemListing).not.toHaveBeenCalled() @@ -753,7 +759,9 @@ describe('WatchlistTable section interactions', () => { }) expect( - container.querySelector('[data-testid="stock-selector-watchlist-listing-editor-listing-1"]') + container.querySelector( + '[data-testid="watchlist-listing-selector-watchlist-listing-editor-listing-1"]' + ) ).toBeNull() expect(onUpdateItemListing).not.toHaveBeenCalled() }) 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 b9be6486b..a626def6a 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 @@ -239,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<string, typeof workflowOutputs> = {} const blockDistances: Record<string, number> = {} @@ -355,11 +355,11 @@ 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 ? ( <div @@ -556,7 +556,7 @@ export function OutputSelect({ )} > <div - className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-xs' + className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-xs bg-secondary text-foreground' style={{ backgroundColor: outputColor ? `${outputColor}20` : undefined, color: outputColor || undefined, diff --git a/apps/tradinggoose/widgets/widgets/workflow_console/components/terminal/components/filter-popover.tsx b/apps/tradinggoose/widgets/widgets/workflow_console/components/terminal/components/filter-popover.tsx index 4932deb68..7f4674626 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_console/components/terminal/components/filter-popover.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_console/components/terminal/components/filter-popover.tsx @@ -11,6 +11,9 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { ScrollArea } from '@/components/ui/scroll-area' +import { useLocale } from 'next-intl' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import type { BlockInfo, TerminalFilters } from '../types' import { getBlockIcon } from '../utils' @@ -34,6 +37,8 @@ export function FilterPopover({ triggerClassName, disabled = false, }: FilterPopoverProps) { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.console return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -42,7 +47,7 @@ export function FilterPopover({ size='icon' className={cn('h-6 w-6', triggerClassName)} onClick={(event) => event.stopPropagation()} - aria-label='Filters' + aria-label={copy.filters} disabled={disabled} > <Filter className={cn('h-4 w-4', hasActiveFilters && 'text-primary')} /> @@ -56,7 +61,7 @@ export function FilterPopover({ <div className='flex max-h-[inherit] flex-col'> <div className='px-1 pt-1'> <DropdownMenuLabel className='px-2 py-1 text-xs text-muted-foreground'> - Status + {copy.status} </DropdownMenuLabel> <DropdownMenuItem onSelect={(event) => { @@ -66,7 +71,7 @@ export function FilterPopover({ className='gap-2' > <div className='h-2 w-2 rounded-sm bg-destructive' /> - <span className='flex-1 text-left'>Error</span> + <span className='flex-1 text-left'>{copy.error}</span> {filters.statuses.has('error') && <Check className='h-3 w-3 text-muted-foreground' />} </DropdownMenuItem> <DropdownMenuItem @@ -77,7 +82,7 @@ export function FilterPopover({ className='gap-2' > <div className='h-2 w-2 rounded-sm bg-emerald-500' /> - <span className='flex-1 text-left'>Info</span> + <span className='flex-1 text-left'>{copy.info}</span> {filters.statuses.has('info') && <Check className='h-3 w-3 text-muted-foreground' />} </DropdownMenuItem> </div> @@ -86,7 +91,7 @@ export function FilterPopover({ <> <DropdownMenuSeparator className='my-1' /> <DropdownMenuLabel className='px-3 py-1 text-xs text-muted-foreground'> - Blocks + {copy.blocks} </DropdownMenuLabel> <div className='px-1 pb-1'> <ScrollArea diff --git a/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx b/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx index c23f8cb01..6e5180565 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx @@ -8,8 +8,11 @@ import { Trash2, WrapText, } from 'lucide-react' +import { useLocale } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' import { useConsoleStore } from '@/stores/console/store' import { useWorkflowWidgetState } from '@/widgets/hooks/use-workflow-widget-state' @@ -38,6 +41,8 @@ const WorkflowConsoleWidgetBody = ({ widget, onWidgetParamsChange, }: WidgetComponentProps) => { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.console const workspaceId = context?.workspaceId const { channelId, @@ -82,7 +87,7 @@ const WorkflowConsoleWidgetBody = ({ }, []) if (!workspaceId) { - return <WidgetStateMessage message='Select a workspace to load workflows.' /> + return <WidgetStateMessage message={copy.selectWorkspace} /> } if (loadError) { @@ -98,7 +103,7 @@ const WorkflowConsoleWidgetBody = ({ } if (workflowIds.length === 0) { - return <WidgetStateMessage message='No workflows available in this workspace.' /> + return <WidgetStateMessage message={copy.noWorkflows} /> } if (!resolvedWorkflowId) { @@ -139,6 +144,8 @@ const WorkflowConsoleHeaderControls = ({ widget, panelId, }: WorkflowConsoleHeaderControlsProps) => { + const locale = useLocale() as LocaleCode + const copy = getPublicCopy(locale).workspace.widgets.console const { resolvedWorkflowId } = useWorkflowWidgetState({ workspaceId, pairColor: widget?.pairColor ?? 'gray', @@ -230,7 +237,7 @@ const WorkflowConsoleHeaderControls = ({ type='button' className={widgetHeaderIconButtonClassName()} onClick={toggleSort} - aria-label='Sort by time' + aria-label={copy.sortByTime} disabled={isDisabled || workflowEntries.length === 0} > {sortConfig.direction === 'desc' ? ( @@ -240,7 +247,7 @@ const WorkflowConsoleHeaderControls = ({ )} </button> </TooltipTrigger> - <TooltipContent side='top'>Sort by time</TooltipContent> + <TooltipContent side='top'>{copy.sortByTime}</TooltipContent> </Tooltip> <Tooltip> @@ -252,14 +259,14 @@ const WorkflowConsoleHeaderControls = ({ detailView.structuredView && 'text-primary' )} onClick={toggleStructuredView} - aria-label='Toggle structured view' + aria-label={copy.toggleStructuredView} aria-pressed={detailView.structuredView} disabled={isDisabled} > <Braces className='h-3.5 w-3.5' /> </button> </TooltipTrigger> - <TooltipContent side='top'>Structured view</TooltipContent> + <TooltipContent side='top'>{copy.structuredView}</TooltipContent> </Tooltip> <Tooltip> @@ -268,14 +275,14 @@ const WorkflowConsoleHeaderControls = ({ type='button' className={cn(widgetHeaderIconButtonClassName(), detailView.wrapText && 'text-primary')} onClick={toggleWrapText} - aria-label='Toggle wrap text' + aria-label={copy.toggleWrapText} aria-pressed={detailView.wrapText} disabled={isDisabled} > <WrapText className='h-3.5 w-3.5' /> </button> </TooltipTrigger> - <TooltipContent side='top'>Wrap text</TooltipContent> + <TooltipContent side='top'>{copy.wrapText}</TooltipContent> </Tooltip> <Tooltip> @@ -284,13 +291,13 @@ const WorkflowConsoleHeaderControls = ({ type='button' className={widgetHeaderIconButtonClassName()} onClick={handleExportConsole} - aria-label='Download console CSV' + aria-label={copy.downloadConsoleCsv} disabled={isDisabled || !hasEntries} > <ArrowDownToLine className='h-3.5 w-3.5' /> </button> </TooltipTrigger> - <TooltipContent side='top'>Download CSV</TooltipContent> + <TooltipContent side='top'>{copy.downloadCsv}</TooltipContent> </Tooltip> <Tooltip> @@ -299,13 +306,13 @@ const WorkflowConsoleHeaderControls = ({ type='button' className={widgetHeaderIconButtonClassName()} onClick={handleClearConsole} - aria-label='Clear console' + aria-label={copy.clearConsole} disabled={isDisabled || !hasEntries} > <Trash2 className='h-3.5 w-3.5' /> </button> </TooltipTrigger> - <TooltipContent side='top'>Clear console</TooltipContent> + <TooltipContent side='top'>{copy.clearConsole}</TooltipContent> </Tooltip> </div> ) diff --git a/apps/tradinggoose/widgets/workflow-labels.test.ts b/apps/tradinggoose/widgets/workflow-labels.test.ts new file mode 100644 index 000000000..9c7c1d2b0 --- /dev/null +++ b/apps/tradinggoose/widgets/workflow-labels.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { translateWorkflowLabel } from './workflow-labels' + +describe('translateWorkflowLabel', () => { + it('translates tools labels and strips trailing colons before lookup', () => { + expect(translateWorkflowLabel('zh-CN', 'Tools')).toBe('工具') + expect(translateWorkflowLabel('zh-CN', 'Response Format:')).toBe('响应格式') + }) +}) diff --git a/apps/tradinggoose/widgets/workflow-labels.ts b/apps/tradinggoose/widgets/workflow-labels.ts new file mode 100644 index 000000000..a0889d020 --- /dev/null +++ b/apps/tradinggoose/widgets/workflow-labels.ts @@ -0,0 +1,153 @@ +import { formatTemplate, getPublicCopy } from '@/i18n/public-copy' +import type { LocaleCode } from '@/i18n/utils' + +type WorkflowToolbarCopy = ReturnType<typeof getPublicCopy>['workspace']['widgets']['workflowToolbar'] +type WorkflowLabelCopy = ReturnType<typeof getPublicCopy>['workspace']['widgets']['workflowLabels'] + +const TEMPERATURE_LABEL_PATTERN = /^Temperature/ +const TRAILING_COLON_PATTERN = /:\s*$/ + +function normalizeWorkflowLabel(label: string) { + return label.replace(TRAILING_COLON_PATTERN, '') +} + +export function getWorkflowToolbarCopy(locale: LocaleCode): WorkflowToolbarCopy { + return getPublicCopy(locale).workspace.widgets.workflowToolbar +} + +export function getWorkflowLabelCopy(locale: LocaleCode): WorkflowLabelCopy { + return getPublicCopy(locale).workspace.widgets.workflowLabels +} + +export function translateWorkflowToolbarLabel(locale: LocaleCode, label: string): string { + const copy = getWorkflowToolbarCopy(locale) + + switch (label) { + case 'Blocks': + return copy.blocks + case 'Tools': + return copy.tools + case 'Triggers': + return copy.triggers + case 'Special': + return copy.special + default: + return label + } +} + +export function translateWorkflowLabel(locale: LocaleCode, label: string): string { + const copy = getWorkflowLabelCopy(locale) + const normalizedLabel = normalizeWorkflowLabel(label) + + switch (normalizedLabel) { + case 'System Prompt': + case 'systemPrompt': + return copy.systemPrompt + case 'User Prompt': + case 'userPrompt': + return copy.userPrompt + case 'Model': + case 'model': + return copy.model + case 'API Key': + case 'apiKey': + return copy.apiKey + case 'Tools': + case 'tools': + return copy.tools + case 'Skills': + case 'skills': + return copy.skills + case 'Response Format': + case 'responseFormat': + return copy.responseFormat + case 'Reasoning Effort': + case 'reasoningEffort': + return copy.reasoningEffort + case 'Verbosity': + case 'verbosity': + return copy.verbosity + case 'Configured': + return copy.configured + case 'value': + return copy.value + case 'items': + return copy.items + case 'fields': + return copy.fields + case 'object': + return copy.object + case 'Block': + return copy.block + case 'Type': + return copy.type + case 'None': + return copy.none + case 'No values to display.': + return copy.noValuesToDisplay + case 'error': + return copy.error + case 'if': + return copy.if + case 'else': + return copy.else + case 'else if': + return copy.elseIf + case 'Add Skill': + return copy.addSkill + case 'Search skills...': + return copy.searchSkills + case 'Choose model': + return copy.chooseModel + case 'Lite': + return copy.lite + case 'Anthropic': + return copy.anthropic + case 'OpenAI': + return copy.openai + case 'Current Workflow': + return copy.currentWorkflow + case 'Current Skill': + return copy.currentSkill + case 'Current Tool': + return copy.currentTool + case 'Current Indicator': + return copy.currentIndicator + case 'Current MCP Server': + return copy.currentMcpServer + case 'Workflows': + return copy.workflows + case 'Custom Tools': + return copy.customTools + case 'Indicators': + return copy.indicators + case 'MCP Servers': + return copy.mcpServers + case 'All workflows': + return copy.allWorkflows + case 'Next Step': + return copy.nextStep + case 'Locked': + return copy.locked + case 'Deployed': + return copy.deployed + case 'Not Deployed': + return copy.notDeployed + case 'Disabled': + return copy.disabled + default: + if (TEMPERATURE_LABEL_PATTERN.test(normalizedLabel)) { + return normalizedLabel.replace(TEMPERATURE_LABEL_PATTERN, copy.temperature) + } + + return label + } +} + +export function formatWorkflowTemplate( + template: string, + values: Record<string, string | number> +): string { + return formatTemplate(template, values) +} diff --git a/bun.lock b/bun.lock index 60fe01bbf..c5f048794 100644 --- a/bun.lock +++ b/bun.lock @@ -164,6 +164,7 @@ "mysql2": "3.14.3", "nanoid": "3.3.11", "next": "16.2.2", + "next-intl": "4.9.1", "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", "node-fetch": "3.3.2", @@ -183,6 +184,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "3.0.6", "react-use": "17.6.0", + "recharts": "3.8.1", "remark-gfm": "4.0.1", "resend": "6.10.0", "sharp": "0.34.3", @@ -649,7 +651,13 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.2", "", {}, "sha512-vPnriihkfK0lzoQGaXq+qXH23VsYyansRTkTgo2aTG0k1NjLFyZimFVdfj4C9JkSE5dm7CEngcQ5TTc1yAyBfQ=="], + + "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.5", "", { "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.5" } }, "sha512-ASMon8BNlKHgQQpZx84xI80EXRS90GlsEU4wEulCKCzrMtUdrfEvFc9UEYmRbvEvtFQLZ4qHXnisUy6PuFjwyA=="], + + "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.5", "", {}, "sha512-9Kc6tMaAPZKTGevdfcvx5zT3v4BTfamo+djJE29wF6ds1QLhoA09MZNDpWMZaebWzuoOTIXhDvgmqmjSlUOGlw=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.4", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.2" } }, "sha512-J51dAnynnqJdVUEXidHoIWn+qYve+yNQEgmFk9Dyfr3p0okzm+5QhQ+9QmsMz08+BeWTVpc1HadIiLfZmRYbAQ=="], "@google-cloud/precise-date": ["@google-cloud/precise-date@4.0.0", "", {}, "sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA=="], @@ -893,6 +901,34 @@ "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -1065,6 +1101,8 @@ "@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], + "@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.4.0", "", {}, "sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], @@ -1121,6 +1159,8 @@ "@s2-dev/streamstore": ["@s2-dev/streamstore@0.17.3", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1" }, "peerDependencies": { "typescript": "^5.9.3" } }, "sha512-UeXL5+MgZQfNkbhCgEDVm7PrV5B3bxh6Zp4C5pUzQQwaoA+iGh2QiiIptRZynWgayzRv4vh0PYfnKpTzJEXegQ=="], + "@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="], + "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], @@ -1251,10 +1291,38 @@ "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@swc/core": ["@swc/core@1.15.32", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.32", "@swc/core-darwin-x64": "1.15.32", "@swc/core-linux-arm-gnueabihf": "1.15.32", "@swc/core-linux-arm64-gnu": "1.15.32", "@swc/core-linux-arm64-musl": "1.15.32", "@swc/core-linux-ppc64-gnu": "1.15.32", "@swc/core-linux-s390x-gnu": "1.15.32", "@swc/core-linux-x64-gnu": "1.15.32", "@swc/core-linux-x64-musl": "1.15.32", "@swc/core-win32-arm64-msvc": "1.15.32", "@swc/core-win32-ia32-msvc": "1.15.32", "@swc/core-win32-x64-msvc": "1.15.32" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-/eWL0n43D64QWEUHLtTE+jDqjkJhyidjkDhv6f0uJohOUAhywxQ9wXYp845DNNds0JpCdI4Uo0a9bl+vbXf+ew=="], + + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.32", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg=="], + + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.32", "", { "os": "darwin", "cpu": "x64" }, "sha512-KOTXJXdAhWL+hZ77MYP3z+4pcMFaQhQ74yqyN1uz093q0YnbxpqMtYpPISbYvMHzVRNNx5kN+9RZAXEaadhWVA=="], + + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.32", "", { "os": "linux", "cpu": "arm" }, "sha512-oOoxLweljlc0A4X8ybsgxV7cVaYTwBOg2iMDJcFR3Sr48C+lsv9VzSmqdK/IVIXF4W4GjLc3VqTAdSMXlfVLuQ=="], + + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.32", "", { "os": "linux", "cpu": "arm64" }, "sha512-oDzEkdl6D6BAWdMtU5KGO7y3HR5fJcvByNLyEk9+ugj8nP5Ovb7P4kBcStBXc4MPExFGQryehiINMlmY8HlclA=="], + + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.32", "", { "os": "linux", "cpu": "arm64" }, "sha512-omcqjoZP/b8D8PuczVoRwJieC6ibj7qIxTftNYokz4/aSmKFHvsd7nIFfPk5ZvtzncbH4AY7+Dkr/Lp2gWxYeA=="], + + "@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.15.32", "", { "os": "linux", "cpu": "ppc64" }, "sha512-KGkTMyz/Tbn3PBNu0AVZ4GTDFKnICrYcTiNPZq8DrvK42pnFsf3GNDrIG9E5AtQlTmC0YigkWKmu0eMcfTrmgA=="], + + "@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.15.32", "", { "os": "linux", "cpu": "s390x" }, "sha512-G3Aa4tVS/3OGZBkoNIwUF9F6RAy+Osb4GOlo62SinLmDiErz/ykmM7KH0wkz6l9kM8jJq1HyAM6atJTUEbBk7g=="], + + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.32", "", { "os": "linux", "cpu": "x64" }, "sha512-ERsjfGcj6CBmj3vJnGDO8m8rTvw6RqMcWo1dogOtNx3/+/0+NNpJiXDobJrr1GwInI/BHAEkvSFIH6d2LqPcUQ=="], + + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.32", "", { "os": "linux", "cpu": "x64" }, "sha512-N4Ggahe/8SUbTX50P6EdhbW9YWcgbZVb52R4cq6MK+zsoMjRq7rGvV5ztA05QnbaCYqMYx8rTY7KAIA3Crdo4Q=="], + + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.32", "", { "os": "win32", "cpu": "arm64" }, "sha512-01yN0o9jvo8xBTP12aPK2wW8b41jmOlGbDDlAnoynotc4pO6xA0zby9f1z6j++qXDpGBttLySq1omgVrlQKYcw=="], + + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.32", "", { "os": "win32", "cpu": "ia32" }, "sha512-fLagI9XZYNpTcmlqAcp3KBtmj7E19WCmYD80Jxj1Kn5tGNa7yxNLd3NNdWxuZGUPl5iC0/KqZru7g08gF6Fsrw=="], + + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.32", "", { "os": "win32", "cpu": "x64" }, "sha512-gbc2bQ/T2CiR+w0OvcVKwLOFAcPZBvmWmolbwpg1E8UrpeC03DGtyMUApOHNXNYWA3SHFrYXCQtosrcMza1YFg=="], + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@swc/types": ["@swc/types@0.1.26", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw=="], + "@t3-oss/env-core": ["@t3-oss/env-core@0.13.4", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0-beta.0" }, "optionalPeers": ["typescript", "valibot", "zod"] }, "sha512-zVOiYO0+CF7EnBScz8s0O5JnJLPTU0lrUi8qhKXfIxIJXvI/jcppSiXXsEJwfB4A6XZawY/Wg/EQGKANi/aPmQ=="], "@t3-oss/env-nextjs": ["@t3-oss/env-nextjs@0.13.4", "", { "dependencies": { "@t3-oss/env-core": "0.13.4" }, "peerDependencies": { "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0-beta.0" }, "optionalPeers": ["typescript", "valibot", "zod"] }, "sha512-6ecXR7SH7zJKVcBODIkB7wV9QLMU23uV8D9ec6P+ULHJ5Ea/YXEHo+Z/2hSYip5i9ptD/qZh8VuOXyldspvTTg=="], @@ -1329,14 +1397,28 @@ "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], @@ -1401,6 +1483,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], @@ -1743,6 +1827,8 @@ "csv-parse": ["csv-parse@6.1.0", "", {}, "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], @@ -1751,10 +1837,22 @@ "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], @@ -1781,6 +1879,8 @@ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], @@ -1909,6 +2009,8 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es-toolkit": ["es-toolkit@1.46.0", "", {}, "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA=="], + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], @@ -2153,12 +2255,16 @@ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "icu-minify": ["icu-minify@4.9.1", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-6NkfF9GHHFouqnz+wuiLjCWQiyxoEyJ5liUv4Jxxo/8wyhV7MY0L0iTEGDAVEa4aAD58WqTxFMa20S5nyMjwNw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="], "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], @@ -2173,6 +2279,10 @@ "inquirer": ["inquirer@8.2.7", "", { "dependencies": { "@inquirer/external-editor": "^1.0.0", "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", "ora": "^5.4.1", "run-async": "^2.4.0", "rxjs": "^7.5.5", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6", "wrap-ansi": "^6.0.1" } }, "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "intl-messageformat": ["intl-messageformat@11.2.2", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.2", "@formatjs/icu-messageformat-parser": "3.5.5" } }, "sha512-yUfyIkPGqMvvk2onw2xBJeLsjXdiYUYebR8mmZVQYBuZUJsFGVht48Ftm1khgu8EZ0n+izX4rAEj3fLAilkh9g=="], + "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], @@ -2593,10 +2703,16 @@ "next": ["next@16.2.2", "", { "dependencies": { "@next/env": "16.2.2", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.2", "@next/swc-darwin-x64": "16.2.2", "@next/swc-linux-arm64-gnu": "16.2.2", "@next/swc-linux-arm64-musl": "16.2.2", "@next/swc-linux-x64-gnu": "16.2.2", "@next/swc-linux-x64-musl": "16.2.2", "@next/swc-win32-arm64-msvc": "16.2.2", "@next/swc-win32-x64-msvc": "16.2.2", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A=="], + "next-intl": ["next-intl@4.9.1", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "^1.15.2", "icu-minify": "^4.9.1", "negotiator": "^1.0.0", "next-intl-swc-plugin-extractor": "^4.9.1", "po-parser": "^2.1.1", "use-intl": "^4.9.1" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-N7ga0CjtYcdxNvaKNIi6eJ2mmatlHK5hp8rt0YO2Omoc1m0gean242/Ukdj6+gJNiReBVcYIjK0HZeNx7CV1ug=="], + + "next-intl-swc-plugin-extractor": ["next-intl-swc-plugin-extractor@4.9.1", "", {}, "sha512-8whJJ6oxJz8JqkHarggmmuEDyXgC7nEnaPhZD91CJwEWW4xp0AST3Mw17YxvHyP2vAF3taWfFbs1maD+WWtz3w=="], + "next-runtime-env": ["next-runtime-env@3.3.0", "", { "dependencies": { "next": "^14", "react": "^18" } }, "sha512-JgKVnog9mNbjbjH9csVpMnz2tB2cT5sLF+7O47i6Ze/s/GoiKdV7dHhJHk1gwXpo6h5qPj5PTzryldtSjvrHuQ=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-ensure": ["node-ensure@0.0.0", "", {}, "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw=="], @@ -2747,6 +2863,8 @@ "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "po-parser": ["po-parser@2.1.1", "", {}, "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ=="], + "postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="], "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], @@ -2829,10 +2947,14 @@ "react-hook-form": ["react-hook-form@7.72.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], "react-medium-image-zoom": ["react-medium-image-zoom@5.4.3", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cDIwdn35fRUPsGnnj/cG6Pacll+z+Mfv6EWU2wDO5ngbZjg5uLRb2ZhEnh92ufbXCJDFvXHekb8G3+oKqUcv5g=="], + "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -2857,6 +2979,8 @@ "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="], + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], @@ -2871,6 +2995,10 @@ "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -2897,6 +3025,8 @@ "require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "resend": ["resend@6.10.0", "", { "dependencies": { "postal-mime": "2.7.4", "svix": "1.88.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-i7CwZpYj4Oho1RxsTpLcCUkO08+HiL4NXrm6jLJ2WzJ89UGI8eROSieLONJA3hnUrf1OYnCyfq5F6POnHUMv1Q=="], "resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="], @@ -3153,6 +3283,8 @@ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -3271,6 +3403,8 @@ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + "use-intl": ["use-intl@4.9.1", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.9.1", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-iGVV/xFYlhe3btafRlL8RPLD2Jsuet4yqn9DR6LWWbMhULsJnXgLonDkzDmsAIBIwFtk02oJuX/Ox2vwHKF+UQ=="], + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], @@ -3287,6 +3421,8 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], @@ -3639,6 +3775,8 @@ "@react-email/tailwind/tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -3773,6 +3911,8 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "fumadocs-core/@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], + "fumadocs-mdx/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], "fumadocs-mdx/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], diff --git a/changelog/April-20-2026.md b/changelog/April-20-2026.md index 1df0e02e0..8b0979fe8 100644 --- a/changelog/April-20-2026.md +++ b/changelog/April-20-2026.md @@ -1,3 +1,4 @@ + # April-20-2026 ## refactor/copilot-db @ 1210ed18 vs origin/staging diff --git a/changelog/April-23-2026.md b/changelog/April-23-2026.md index dbc0256e8..43d85e8c5 100644 --- a/changelog/April-23-2026.md +++ b/changelog/April-23-2026.md @@ -1,5 +1,66 @@ # April-23-2026 +## docker_files @ f2bc8336 vs origin/staging + +### Summary +- Standardizes the Docker and compose flow around pinned Bun 1.3.11 images, a dedicated guardrails build stage, and a bundled realtime socket-server artifact so the app and realtime containers stay smaller and more predictable. +- Tightens deployment configuration by making Redis, PostgreSQL credentials, auth secrets, encryption keys, and image tags explicit in local, Ollama, and production compose files instead of relying on implicit defaults or generated values. +- Simplifies image publishing to one multi-platform workflow that pushes GHCR everywhere and Docker Hub on `main`, then updates the docs and env templates to match the new contract. + +### Branch Scope +- Compared `1309ad1279fb0af551a7aad47764c43a5fd1b3d8..f2bc8336` against `origin/staging`. +- The working tree was clean during this review, so only committed branch changes are included here. +- Main areas touched: `.dockerignore`, `.github/CONTRIBUTING.md`, `.github/workflows/images.yml`, `README.md`, `apps/tradinggoose/.env.example`, `apps/tradinggoose/.env.example.docker`, `apps/tradinggoose/lib/guardrails/requirements.txt`, `docker-compose.local.yml`, `docker-compose.ollama.yml`, `docker-compose.prod.yml`, `docker/app.Dockerfile`, `docker/db.Dockerfile`, and `docker/realtime.Dockerfile`. + +### Key Changes +- `docker/app.Dockerfile` now builds on `oven/bun:1.3.11-alpine`, pins `turbo@2.5.8` and `sharp@0.34.3`, splits guardrails installation into its own `guardrails` stage, and copies the runtime guardrails virtualenv into the final image instead of rebuilding it in place. +- The guardrails runtime copy paths now match the build stage layout: `setup.sh` installs the virtualenv under `/app/lib/guardrails/venv`, so the final image copies `venv` and `validate_pii.py` from `/app/lib/guardrails` instead of a temporary build directory. +- The runner stage now copies only the Bun `lib0` workspace symlink from `apps/tradinggoose/node_modules` instead of the whole app dependency tree. The standalone Next output already carries the other workspace dependencies, and the narrower copy keeps Yjs resolution intact without clobbering `monaco-editor` in the runtime image. +- `docker/realtime.Dockerfile` now bundles `apps/tradinggoose/socket-server/index.ts` into `/tmp/realtime-build/socket-server.js` and ships only that artifact in the runtime image, rather than copying the full app tree, package tree, and root `package.json` into the container. +- `docker/db.Dockerfile` narrows the migration image to the files `packages/db` actually needs at runtime: `package.json`, `drizzle.config.ts`, `schema.ts`, `consts.ts`, `schema/`, and `migrations/`. +- `.github/workflows/images.yml` collapses the previous AMD64/ARM64 split plus ECR workflow into one matrix job that builds `docker/app.Dockerfile`, `docker/db.Dockerfile`, and `docker/realtime.Dockerfile` for `linux/amd64,linux/arm64`, pushes SHA tags everywhere, and publishes `latest` only on `main` for GHCR and Docker Hub. +- `docker-compose.local.yml`, `docker-compose.ollama.yml`, and `docker-compose.prod.yml` now require explicit `POSTGRES_USER`, `POSTGRES_PASSWORD`, `NEXT_PUBLIC_APP_URL`, `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, `INTERNAL_API_SECRET`, and image tags where applicable, add a Redis service dependency, and wire `REDIS_URL=redis://redis:6379` into the app and realtime containers. +- `docker-compose.local.yml` and `docker-compose.ollama.yml` now default `NEXT_PUBLIC_SOCKET_URL` to `http://localhost:3002`, while `docker-compose.prod.yml` requires a browser-reachable explicit value instead of hardcoding the internal Docker service name. +- `apps/tradinggoose/.env.example.docker` now includes `IMAGE_TAG` and `OLLAMA_IMAGE_TAG` so the compose manifests document the published image contract alongside the app secrets. +- `docker-compose.ollama.yml` also switches Ollama to `OLLAMA_IMAGE_TAG` instead of `latest`, keeps the GPU and CPU services separate, and makes the setup helper reference the actual service name when explaining how to pull extra models. +- `.dockerignore` now excludes repo metadata, build output, cache directories, environment files, logs, and local data volumes more aggressively so Docker builds stay focused on source inputs. +- `apps/tradinggoose/.env.example` and `apps/tradinggoose/.env.example.docker` now document the explicit local boot variables, including the Redis URL, socket URL, and compose-specific database/bootstrap defaults. +- `.github/CONTRIBUTING.md` and `README.md` now explain the new compose bootstrap flow and the need to copy the appropriate env template before running Docker-based setups. + +### Design Decisions +- The branch makes the runtime contract explicit instead of hiding it behind generated secrets or image defaults. That keeps local Docker, production compose, and CI image publishing aligned on the same required inputs. +- Redis is treated as a first-class dependency for containerized runs rather than an optional external service, which is why every compose stack now depends on the local Redis container and exports the same `REDIS_URL`. +- The realtime container now owns a bundled socket-server artifact only. That reduces runtime image surface area and keeps the build/runtime split clear: bundle in the Dockerfile, execute the compiled artifact in the final image. +- Guardrails setup is isolated in a dedicated build stage so Python package installation does not leak into the app runtime image beyond the prepared virtualenv. +- The image workflow now publishes multi-platform images from one job instead of maintaining separate build, arm64, and manifest-push jobs. That keeps the publishing path simpler and avoids the previous AWS/ECR dependency chain. + +### Shared Contracts and Helpers to Reuse +- Reuse `apps/tradinggoose/.env.example` for app-only local development and `apps/tradinggoose/.env.example.docker` for Docker Compose setups. Those files now define the canonical required env surface for each path. +- Reuse the explicit `:?` interpolation pattern in `docker-compose.local.yml`, `docker-compose.ollama.yml`, and `docker-compose.prod.yml` when adding new compose services that should fail fast on missing secrets or tags. +- Reuse `docker/app.Dockerfile`, `docker/realtime.Dockerfile`, and `docker/db.Dockerfile` as the canonical container build patterns for app, socket server, and migrations work instead of copying full source trees into new images. +- Reuse the single-job publishing model in `.github/workflows/images.yml` for future image variants so tag generation and platform coverage stay centralized. + +### Removed or Replaced Items +- The old implicit env defaults for PostgreSQL credentials, auth secrets, encryption keys, and image tags were replaced. Future branches should not reintroduce silent fallbacks such as `postgres`, `latest`, or generated compose-time secrets for these paths. +- The previous separate AMD64/ARM64 build jobs, manifest assembly job, and AWS/ECR login path in `.github/workflows/images.yml` were replaced by the single GHCR/Docker Hub workflow. +- The realtime image no longer copies the full app and package trees into the runtime layer. The replacement is the bundled `socket-server.js` artifact produced during the Docker build. +- The db image no longer copies the whole `packages/db` directory. The replacement is the explicit migration input set copied in `docker/db.Dockerfile`. + +### Future Branch Guardrails +- Do not add new compose services that depend on hidden defaults when the rest of the stack now expects explicit `:?`-guarded env vars. +- Do not reintroduce `latest`-only image references for production or compose-based local boot; use tagged images and make the tag requirement visible in the env file. +- Do not point `NEXT_PUBLIC_SOCKET_URL` at Docker-internal hostnames such as `realtime`; the browser reads this env directly, so local Compose should use `http://localhost:3002` and production should override it with a public URL. +- Do not expand the realtime runtime image back into a full app image unless the socket-server bundling strategy changes with it. +- Do not restore the full `apps/tradinggoose/node_modules` copy in `docker/app.Dockerfile`; the runner image only needs the explicit `lib0` symlink to keep Yjs working. +- Do not move guardrails installation back into the main app runtime layer; keep the isolated build stage and copy only its prepared virtualenv forward. +- Do not split the image publishing path back into separate platform-specific jobs unless the registry or platform requirements genuinely change. + +### Validation Notes +- Reviewed `git status --short --branch`, `git log --oneline origin/staging..HEAD`, `git diff --stat origin/staging...HEAD`, and `git diff --name-status --find-renames origin/staging...HEAD`. +- Verified patch hygiene with `git diff --check origin/staging...HEAD`. +- Validated all three compose files with `docker compose -f docker-compose.local.yml config`, `docker compose -f docker-compose.ollama.yml config`, and `docker compose -f docker-compose.prod.yml config` using explicit placeholder env values. +- No application tests were added or updated in this branch; the change set is Docker, compose, and documentation focused. + ## fix/copilot-billing @ 6a60cc11 vs origin/staging ### Summary diff --git a/changelog/April-29-2026.md b/changelog/April-29-2026.md new file mode 100644 index 000000000..5bfbc6f9c --- /dev/null +++ b/changelog/April-29-2026.md @@ -0,0 +1,46 @@ +# April-29-2026 + +## fix/copilot-render @ 7506fe2 vs origin/staging + +### Summary +- Fixes Copilot assistant rendering so JSON-prefixed reasoning envelopes are rendered as thinking blocks instead of leaking into visible markdown text. +- Keeps thinking block labels and unknown-duration rendering stable in `apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx`. +- Preserves content-level reasoning when persisted assistant messages already include other content blocks, such as tool calls. +- Adds a review follow-up so synthesized content-level reasoning blocks use a deterministic timestamp derived from the persisted message timestamp. + +### Branch Scope +- Compared `d9ee4fce7e4e2a266fbf27719c2238c992d24886..fix/copilot-render` against `origin/staging`. +- The staged review follow-up in this branch updates the same Copilot message normalization surface and this changelog file. +- Main areas touched: `apps/tradinggoose/stores/copilot/store-messages.ts`, `apps/tradinggoose/stores/copilot/store-messages.test.ts`, and `apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx`. + +### Key Changes +- `apps/tradinggoose/stores/copilot/store-messages.ts` now normalizes assistant JSON reasoning envelopes into `thinking` content blocks while keeping the visible assistant reply separate. +- `apps/tradinggoose/stores/copilot/store-messages.ts` prepends content-level reasoning to existing content blocks when the persisted assistant message already has tool-call blocks. +- `apps/tradinggoose/stores/copilot/store-messages.ts` now derives synthesized content-level block timestamps from `message.timestamp`, keeping repeated `normalizeMessagesForUI()` calls deterministic for the same input message. +- `apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx` renders completed thinking with markdown content and avoids showing misleading unknown durations. +- `apps/tradinggoose/stores/copilot/store-messages.test.ts` and `apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.test.tsx` cover the reasoning normalization and thinking group rendering paths. + +### Design Decisions +- Reasoning extracted from persisted message content belongs in a `thinking` content block so the visible message body remains user-facing reply text only. +- Synthetic content-level blocks reuse the persisted message timestamp instead of wall-clock time so normalization is idempotent and safe for React rendering. +- The renderer keeps thinking markdown support inside the existing thinking group component rather than introducing a parallel markdown surface. + +### Shared Contracts and Helpers to Reuse +- Reuse `normalizeMessagesForUI()` in `apps/tradinggoose/stores/copilot/store-messages.ts` as the canonical UI normalization path for persisted Copilot messages. +- Reuse the `thinking` content block shape from `apps/tradinggoose/stores/copilot/types.ts` when adding future reasoning display behavior. +- Reuse `ThinkingGroup` in `apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx` for reasoning block rendering instead of rendering reasoning in the assistant body. + +### Removed or Replaced Items +- Raw JSON reasoning envelopes are no longer left in visible assistant message text. +- Fresh `Date.now()` timestamps for content-level synthesized reasoning/text blocks were replaced with timestamps derived from the persisted assistant message. +- No files were deleted in this branch. + +### Future Branch Guardrails +- Do not render persisted reasoning JSON directly in the visible assistant markdown body. +- Do not synthesize content-level Copilot blocks with fresh wall-clock timestamps when a persisted message timestamp is available. +- Do not duplicate thinking markdown rendering outside `ThinkingGroup`. + +### Validation Notes +- Reviewed `git merge-base origin/staging fix/copilot-render`, `git log --oneline origin/staging..fix/copilot-render`, and `git diff --stat origin/staging...fix/copilot-render`. +- Inspected `apps/tradinggoose/stores/copilot/store-messages.ts`, `apps/tradinggoose/stores/copilot/store-messages.test.ts`, and existing changelog entries for repository format. +- Ran the focused Copilot message normalization test file after the review follow-up. diff --git a/changelog/May-03-2026.md b/changelog/May-03-2026.md new file mode 100644 index 000000000..7b9a13c26 --- /dev/null +++ b/changelog/May-03-2026.md @@ -0,0 +1,88 @@ +# May-03-2026 + +## feat/portfolio-widgets @ 63f4b6e0 vs origin/staging + +### Summary +- Adds the Trading widget suite: Portfolio Snapshot, Quick Order, and Heatmap are registered through `apps/tradinggoose/widgets/registry.tsx`, parameter events in `apps/tradinggoose/widgets/events.ts`, and widget-specific param persistence helpers under `apps/tradinggoose/widgets/utils/*-params.ts`. +- Builds a shared trading data layer around Alpaca and Tradier accounts, portfolio snapshots, performance windows, order validation, listing resolution, and broker request handling under `apps/tradinggoose/providers/trading/*`, `apps/tradinggoose/app/api/providers/trading/*`, and `apps/tradinggoose/socket-server/trading/portfolio-manager.ts`. +- Centralizes market provider settings, TradingGoose Market proxy requests, quote snapshots, and listing resolution so widgets can reuse one market snapshot/search contract instead of issuing parallel provider calls. +- Replaces older widget and workflow paths with reusable header controls, a unified listing selector, browser-side indicator execution, and the simplified workflow auto-layout path. + +### Branch Scope +- Compared `7d4834520a0066a0e923d35bfd90a0614b0393d5..feat/portfolio-widgets` against `origin/staging`. +- The branch head is `63f4b6e0`. This documentation pass also includes the uncommitted review follow-up in `apps/tradinggoose/providers/trading/alpaca/config.ts`, `apps/tradinggoose/providers/trading/order-types.test.ts`, and `apps/tradinggoose/providers/trading/utils.test.ts` that keeps Alpaca crypto availability aligned with its crypto base/quote lists and strict order types. +- Main areas touched: `apps/tradinggoose/widgets/widgets/{portfolio_snapshot,quick_order,heatmap,watchlist,data_chart}`, `apps/tradinggoose/widgets/widgets/components/*`, `apps/tradinggoose/widgets/utils/*`, `apps/tradinggoose/providers/{trading,market}/*`, `apps/tradinggoose/app/api/{providers/trading,market,auth/oauth,workflows}`, `apps/tradinggoose/socket-server/{market,trading}`, `apps/tradinggoose/lib/{market,listing,indicators,workflows,copilot}`, `apps/tradinggoose/tools/trading/*`, `apps/tradinggoose/blocks/blocks/trading_*`, `apps/tradinggoose/components/ui/chart.tsx`, `apps/tradinggoose/package.json`, `bun.lock`, and `changelog/April-20-2026.md`. + +### Key Changes +- `apps/tradinggoose/widgets/widgets/portfolio_snapshot/*` adds a trading portfolio widget that selects a broker credential/account, streams `UnifiedTradingAccountSnapshot` and `UnifiedTradingPortfolioPerformance` through `useTradingPortfolioSnapshot()` / `useTradingPortfolioPerformance()`, resolves position listings, and renders performance with the new Recharts-backed `apps/tradinggoose/components/ui/chart.tsx`. +- `apps/tradinggoose/widgets/widgets/quick_order/*` adds a broker-backed order ticket with shared market/trading provider controls, listing search in `providerType='trading'`, quote snapshots for the selected listing, account buying-power context, strict provider order-type filtering, sizing validation, and submission through `useSubmitTradingOrder()` to `apps/tradinggoose/app/api/providers/trading/order/route.ts`. +- `apps/tradinggoose/widgets/widgets/heatmap/*` adds a market heatmap widget with watchlist and portfolio source modes, quote snapshot polling, portfolio position listing support, color/format helpers, and a tested treemap layout in `apps/tradinggoose/widgets/widgets/heatmap/utils/treemap-layout.ts`. +- `apps/tradinggoose/providers/trading/types.ts`, `portfolio.ts`, `portfolio-utils.ts`, `listing-resolution.ts`, `order-types.ts`, `order-validation.ts`, and `utils.ts` define the unified trading account, cash, position, performance, listing, order-type, and validation contracts shared by widgets, workflow tools, and API routes. +- `apps/tradinggoose/providers/trading/alpaca/{accounts,snapshot,performance,positions,orders}.ts` and `apps/tradinggoose/providers/trading/tradier/{accounts,snapshot,performance,positions,orders}.ts` normalize broker-specific account snapshots, performance windows, order requests, holdings, positions, and order detail behavior into the shared trading contracts. +- `apps/tradinggoose/app/api/providers/trading/shared.ts` centralizes trading provider preflight, OAuth credential service resolution, selected-account lookup, request ids, and broker failure logging; `apps/tradinggoose/app/api/providers/trading/order/route.ts` uses it for Quick Order request parsing, provider support checks, broker request execution, and normalized order responses. +- `apps/tradinggoose/providers/trading/providers.ts`, `apps/tradinggoose/app/api/auth/oauth/{credentials,token,connections,disconnect}.ts`, `apps/tradinggoose/hooks/queries/oauth-{credentials,provider-availability}.ts`, and `apps/tradinggoose/widgets/widgets/components/trading-credential-services.ts` now model trading OAuth by credential service id so Alpaca live, Alpaca paper, and Tradier can be selected and checked independently. +- `apps/tradinggoose/socket-server/trading/portfolio-manager.ts`, `apps/tradinggoose/socket-server/handlers/trading.ts`, and `apps/tradinggoose/hooks/queries/trading-portfolio.ts` add socket channels for `accounts`, `account-snapshot`, and `portfolio-performance`, including refresh/unsubscribe flows and listing resolution for streamed positions. +- `apps/tradinggoose/lib/market/request-gate.ts` moves TradingGoose Market outbound calls, service configuration, API-key handling, response caching, and in-flight dedupe into one helper; `apps/tradinggoose/app/api/market/proxy.ts` and `apps/tradinggoose/app/api/market/api-keys/shared.ts` now route through that helper. +- `apps/tradinggoose/lib/market/quote-snapshot-contract.ts`, `apps/tradinggoose/lib/market/quote-snapshots.ts`, `apps/tradinggoose/hooks/queries/market-quote-snapshots.ts`, and `apps/tradinggoose/socket-server/market/manager.ts` add the shared quote snapshot contract, request cap, stream events, polling support for non-stream providers, and widget hook used by watchlist, portfolio, Quick Order, and heatmap surfaces. +- `apps/tradinggoose/widgets/widgets/components/{market-provider-controls,market-provider-settings-button,trading-provider-controls,trading-account-selector,trading-provider-selector,widget-header-refresh-button}.tsx` add reusable widget header controls for market provider config, broker credentials/accounts, and refresh actions; watchlist and Data Chart headers were adjusted to reuse those contracts. +- `apps/tradinggoose/widgets/widgets/components/listing-selector.tsx`, `apps/tradinggoose/components/listing-selector/selector/use-listing-search.ts`, `apps/tradinggoose/components/listing-selector/selector/resolve-request.ts`, `apps/tradinggoose/hooks/queries/listing-resolution.ts`, and `apps/tradinggoose/lib/listing/{identity,resolve}.ts` consolidate listing identity hydration/search for both market and trading provider contexts. +- `apps/tradinggoose/lib/indicators/browser-execution.ts`, `apps/tradinggoose/lib/indicators/trigger-capture.ts`, `apps/tradinggoose/lib/indicators/trigger-bridge.ts`, and `apps/tradinggoose/widgets/widgets/data_chart/hooks/use-indicator-sync.ts` move Pine indicator execution and trigger capture into the browser execution path while keeping shared trigger sentinel parsing. +- `apps/tradinggoose/lib/workflows/autolayout/index.ts`, `positioning.ts`, `types.ts`, `apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts`, and `apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts` simplify workflow auto layout around provided live block/edge state, one layout result shape, and Yjs/database persistence from the editor control. +- `apps/tradinggoose/stores/dashboard/pair-store.ts`, `apps/tradinggoose/widgets/hooks/use-workflow-widget-state.ts`, `apps/tradinggoose/widgets/layout.ts`, and the entity review utilities normalize pair-color context and workflow widget activation so widgets share `ListingIdentity` and workflow context without older ad hoc state shapes. +- `apps/tradinggoose/stores/copilot/tool-registry.ts` and `apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts` split server-managed Copilot tool metadata from client tool constructors, while related Copilot replay/inline-tool-call files update rendering and replay safety around that registry. +- `apps/tradinggoose/tools/trading/{action,holdings,order_detail,types}.ts` and `apps/tradinggoose/blocks/blocks/trading_{action,holdings,order_detail}.ts` now use the unified trading provider contracts, credential service ids, provider param catalogs, and normalized order/holdings outputs. + +### Design Decisions +- Trading provider support is broker-normalized before it reaches widgets. `apps/tradinggoose/providers/trading/types.ts` owns the UI-facing shapes, provider modules own broker translation, and sockets/API routes pass those normalized shapes forward. +- OAuth service ids are explicit because a provider id is not enough for Alpaca. Branch code resolves Alpaca live/paper and Tradier through `credentialServiceId` instead of relying on an environment fallback hidden inside the widget. +- Quote snapshots are a shared market contract instead of widget-specific polling. `MARKET_QUOTE_SNAPSHOT_REQUEST_CAP` limits batch size, the socket manager owns stream/poll behavior, and hooks expose aliased results by listing identity. +- Quick Order and Portfolio Snapshot reuse the same broker account and provider controls so account selection, OAuth-required modals, and provider availability checks stay identical across trading widgets. +- Market provider credentials and required provider params are stored in widget params through `sanitizeMarketProviderAuth()` and `sanitizeMarketProviderParamsForWidget()` so widgets keep their own provider configuration without persisting empty or invalid values. +- Indicator execution moved out of the server route because Data Chart can execute Pine indicators in the browser with the same trigger-capture contract, removing a server-only API path and its extra concurrency/auth surface. +- Workflow auto layout now favors one canonical layout pass over incremental/targeted helper paths. The editor sends live blocks/edges to the API, the API calls `applyAutoLayout()`, and the editor persists the returned block positions through Yjs plus the workflow state route. +- Copilot tool display ownership is separated by execution kind: client tools still provide constructors and metadata, while server tools use `SERVER_TOOL_METADATA` for display and interrupt text without registering fake client instances. + +### Shared Contracts and Helpers to Reuse +- Reuse `UnifiedTradingAccount`, `UnifiedTradingAccountSnapshot`, `UnifiedTradingPosition`, `UnifiedTradingPortfolioPerformance`, `TradingProviderAvailability`, and `TradingOperationKind` from `apps/tradinggoose/providers/trading/types.ts` for any future broker-facing UI or workflow tool. +- Reuse `listTradingAccounts()`, `getTradingAccountSnapshot()`, `getTradingAccountPerformance()`, `getTradingPortfolioSupportedWindows()`, and `isTradingPortfolioWindowSupported()` from `apps/tradinggoose/providers/trading/portfolio.ts` instead of calling broker modules directly from UI code. +- Reuse `resolveTradingPositionListingIdentity()` from `apps/tradinggoose/providers/trading/listing-resolution.ts` and `useResolvedListings()` from `apps/tradinggoose/hooks/queries/listing-resolution.ts` when broker symbols need canonical `ListingIdentity`/resolved listing metadata. +- Reuse `getStrictTradingOrderTypeDefinitions()`, `getTradingOrderTypeDefinitions()`, `getTradingOrderTypeOptions()`, `isTradingOrderListingSupported()`, `listingIdentityToTradingSymbol()`, and `tradingSymbolToListingIdentity()` from `apps/tradinggoose/providers/trading/{order-types,utils}.ts` for order tickets, workflow blocks, and future broker tools. +- Reuse `resolveTradingProviderPreflight()`, `resolveTradingProviderContext()`, and `resolveTradingProviderSelectedAccount()` from `apps/tradinggoose/app/api/providers/trading/shared.ts` for trading API routes that need OAuth credential service/account resolution. +- Reuse `useTradingAccounts()`, `useTradingPortfolioSnapshot()`, `useTradingPortfolioPerformance()`, and `useSubmitTradingOrder()` from `apps/tradinggoose/hooks/queries/trading-portfolio.ts` for trading UI instead of creating route-specific fetch hooks. +- Reuse `TradingPortfolioStreamManager` in `apps/tradinggoose/socket-server/trading/portfolio-manager.ts` and the `trading-portfolio-*` socket events wired by `apps/tradinggoose/socket-server/handlers/trading.ts` for account, snapshot, and performance streaming. +- Reuse `requestTradingGooseMarket()` from `apps/tradinggoose/lib/market/request-gate.ts`, `MarketQuoteSnapshot` / `MARKET_QUOTE_SNAPSHOT_REQUEST_CAP` from `apps/tradinggoose/lib/market/quote-snapshot-contract.ts`, `buildMarketQuoteSnapshot()` from `apps/tradinggoose/lib/market/quote-snapshots.ts`, and `useMarketQuoteSnapshots()` from `apps/tradinggoose/hooks/queries/market-quote-snapshots.ts`. +- Reuse `MarketProviderControls`, `MarketProviderSettingsButton`, `TradingProviderControls`, `TradingAccountSelector`, and `WidgetHeaderRefreshButton` from `apps/tradinggoose/widgets/widgets/components/*` for widget headers. +- Reuse `sanitizeMarketProviderAuth()`, `sanitizeMarketProviderParamsForWidget()`, and `resolveMarketProviderSettingsDefinitions()` from `apps/tradinggoose/lib/market/market-provider-settings.ts` when persisting widget-level market provider settings. +- Reuse `getTradingWidgetProviderAvailabilityIds()`, `getTradingWidgetProviderOptions()`, and `resolveTradingWidgetProviderId()` from `apps/tradinggoose/widgets/utils/trading-widget-providers.ts` for trading widget provider filtering. +- Reuse `executeBrowserPineIndicator()` from `apps/tradinggoose/lib/indicators/browser-execution.ts` plus the trigger sentinel helpers in `apps/tradinggoose/lib/indicators/trigger-capture.ts` for future client-side indicator execution. +- Reuse `applyAutoLayout()` from `apps/tradinggoose/lib/workflows/autolayout/index.ts` and the editor helper `applyAutoLayoutAndUpdateStore()` from `apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts` for workflow layout changes. +- Reuse `SERVER_TOOL_METADATA` from `apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts` and `getCopilotToolDefinition()` from `apps/tradinggoose/stores/copilot/tool-registry.ts` for Copilot tool display/registry work. + +### Removed or Replaced Items +- `apps/tradinggoose/app/api/indicators/execute/route.ts` was deleted. The replacement is browser-side execution through `apps/tradinggoose/lib/indicators/browser-execution.ts` and `apps/tradinggoose/widgets/widgets/data_chart/hooks/use-indicator-sync.ts`. +- `apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector.tsx` and `stock-selector-dropdown.tsx` were deleted. The replacement is `WatchlistListingSelector` in `apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.tsx`, which wraps the shared `ListingSelector`. +- `apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-refresh-data-button.tsx` was deleted. The replacement is `WidgetHeaderRefreshButton` in `apps/tradinggoose/widgets/widgets/components/widget-header-refresh-button.tsx`. +- `apps/tradinggoose/lib/workflows/autolayout/incremental.ts` and `targeted.ts` were deleted. The replacement is the canonical `applyAutoLayout()` flow in `apps/tradinggoose/lib/workflows/autolayout/index.ts` plus `calculatePositions()` in `positioning.ts`. +- Direct TradingGoose Market proxy/request handling in API routes was replaced by `requestTradingGooseMarket()` in `apps/tradinggoose/lib/market/request-gate.ts`; future routes should not re-create API-key, cache, or in-flight request handling. +- Widget-specific market provider settings forms were replaced by `MarketProviderSettingsButton` and `apps/tradinggoose/lib/market/market-provider-settings.ts`. +- The prior provider-id-only OAuth assumption for trading widgets was replaced by credential service ids. Do not restore logic that treats `alpaca` alone as enough to choose live versus paper accounts. +- The uncommitted review follow-up replaces the inconsistent Alpaca state where crypto bases/quotes were advertised while `availability.assetClass` only listed `stock`; Alpaca now advertises `stock` and `crypto` together and tests lock the Quick Order support path. + +### Future Branch Guardrails +- Do not add trading widgets or trading workflow routes that bypass `apps/tradinggoose/providers/trading/*`, `apps/tradinggoose/app/api/providers/trading/shared.ts`, or `apps/tradinggoose/hooks/queries/trading-portfolio.ts`. +- Keep Alpaca `availability.assetClass`, `availableCryptoBase`, `availableCryptoQuote`, and crypto-capable order type definitions consistent. If crypto ordering is disabled later, remove the crypto lists and tests together instead of leaving a half-advertised state. +- Do not fetch quote snapshots directly from widgets. Use `useMarketQuoteSnapshots()` and respect `MARKET_QUOTE_SNAPSHOT_REQUEST_CAP`. +- Do not reintroduce `apps/tradinggoose/app/api/indicators/execute/route.ts` unless the browser execution contract is intentionally replaced in the same branch. +- Do not restore the deleted watchlist stock selector or refresh button. Use `ListingSelector`/`WatchlistListingSelector` and `WidgetHeaderRefreshButton`. +- Do not recreate incremental or targeted auto-layout modules. Extend `applyAutoLayout()` / `calculatePositions()` and the editor API/store persistence path if layout behavior changes. +- Do not persist unsanitized market provider params or credentials into widget params. Route writes through `sanitizeMarketProviderAuth()` and `sanitizeMarketProviderParamsForWidget()`. +- Do not create a second Copilot tool metadata table. Client and server tool display ownership now lives in `apps/tradinggoose/stores/copilot/tool-registry.ts` and `apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts`. +- Do not add new widget categories or registry entries without updating `apps/tradinggoose/widgets/registry.tsx`, `apps/tradinggoose/widgets/registry.test.ts`, and the relevant param event/persistence helper. + +### Validation Notes +- Reviewed `git status --short`, `git fetch origin staging`, `git merge-base origin/staging feat/portfolio-widgets`, `git log --oneline 7d4834520a0066a0e923d35bfd90a0614b0393d5..feat/portfolio-widgets`, `git diff --stat 7d4834520a0066a0e923d35bfd90a0614b0393d5`, and `git diff --name-status --find-renames 7d4834520a0066a0e923d35bfd90a0614b0393d5`. +- Inspected the owning files for the documented contracts, including `apps/tradinggoose/providers/trading/{types,providers,portfolio,portfolio-utils,listing-resolution,order-types,order-validation,utils}.ts`, broker modules under `apps/tradinggoose/providers/trading/{alpaca,tradier}`, `apps/tradinggoose/app/api/providers/trading/*`, `apps/tradinggoose/socket-server/{market,trading}/*`, `apps/tradinggoose/hooks/queries/{trading-portfolio,market-quote-snapshots,listing-resolution,oauth-provider-availability}.ts`, `apps/tradinggoose/lib/market/*`, `apps/tradinggoose/widgets/widgets/{portfolio_snapshot,quick_order,heatmap,watchlist,data_chart}`, `apps/tradinggoose/widgets/widgets/components/*`, `apps/tradinggoose/lib/indicators/*`, `apps/tradinggoose/lib/workflows/autolayout/*`, `apps/tradinggoose/stores/dashboard/pair-store.ts`, `apps/tradinggoose/stores/copilot/tool-registry.ts`, and `apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts`. +- Verified the review follow-up with `git diff -- apps/tradinggoose/providers/trading/alpaca/config.ts apps/tradinggoose/providers/trading/order-types.test.ts apps/tradinggoose/providers/trading/utils.test.ts`. +- Previously ran focused validation for the review follow-up with `bunx vitest run providers/trading widgets/widgets/quick_order` from `apps/tradinggoose`; 15 files and 85 tests passed. +- Previously ran root `bun run type-check`; it passed after removing the ignored stale generated file `apps/tradinggoose/.next/dev/types/validator.ts`. +- Ran `git diff --check`; it passed. diff --git a/docker-compose.local.yml b/docker-compose.local.yml index f93d44d4f..81cf11965 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,26 +1,34 @@ services: + redis: + image: redis:7.2.1-alpine + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 5 tradinggoose: build: context: . dockerfile: docker/app.Dockerfile ports: - '3000:3000' - deploy: - resources: - limits: - memory: 8G environment: - - NODE_ENV=development - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - NODE_ENV=production + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here} - - COPILOT_API_KEY=${COPILOT_API_KEY} - - COPILOT_API_URL=${COPILOT_API_URL} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - REDIS_URL=redis://redis:6379 + - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} depends_on: + redis: + condition: service_healthy db: condition: service_healthy migrations: @@ -39,20 +47,22 @@ services: context: . dockerfile: docker/realtime.Dockerfile environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - NODE_ENV=production + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} + - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - REDIS_URL=redis://redis:6379 depends_on: + redis: + condition: service_healthy db: condition: service_healthy restart: unless-stopped ports: - '3002:3002' - deploy: - resources: - limits: - memory: 8G healthcheck: test: ['CMD', 'wget', '--spider', '--quiet', 'http://127.0.0.1:3002/health'] interval: 90s @@ -66,7 +76,7 @@ services: dockerfile: docker/db.Dockerfile working_dir: /app/packages/db environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} depends_on: db: condition: service_healthy @@ -79,13 +89,13 @@ services: ports: - '${POSTGRES_PORT:-5432}:5432' environment: - - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_USER=${POSTGRES_USER:?set POSTGRES_USER in .env} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} - POSTGRES_DB=${POSTGRES_DB:-tradinggoose} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres'] + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:?set POSTGRES_USER in .env}'] interval: 5s timeout: 5s retries: 5 diff --git a/docker-compose.ollama.yml b/docker-compose.ollama.yml index ae265073c..ddc91e53d 100644 --- a/docker-compose.ollama.yml +++ b/docker-compose.ollama.yml @@ -1,6 +1,12 @@ name: tradinggoose-with-ollama - services: + redis: + image: redis:7.2.1-alpine + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 5 # Main TradingGoose Studio Application tradinggoose: build: @@ -8,21 +14,23 @@ services: dockerfile: docker/app.Dockerfile ports: - '3000:3000' - deploy: - resources: - limits: - memory: 8G environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - NODE_ENV=production + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-tradinggoose_auth_secret_$(openssl rand -hex 16)} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-$(openssl rand -hex 32)} - - COPILOT_API_KEY=${COPILOT_API_KEY} - - COPILOT_API_URL=${COPILOT_API_URL} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - REDIS_URL=redis://redis:6379 + - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=http://ollama:11434 - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} depends_on: + redis: + condition: service_healthy db: condition: service_healthy migrations: @@ -43,20 +51,22 @@ services: context: . dockerfile: docker/realtime.Dockerfile environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - NODE_ENV=production + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-tradinggoose_auth_secret_$(openssl rand -hex 16)} + - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - REDIS_URL=redis://redis:6379 depends_on: + redis: + condition: service_healthy db: condition: service_healthy restart: unless-stopped ports: - '3002:3002' - deploy: - resources: - limits: - memory: 8G healthcheck: test: ['CMD', 'wget', '--spider', '--quiet', 'http://127.0.0.1:3002/health'] interval: 90s @@ -70,7 +80,7 @@ services: context: . dockerfile: docker/db.Dockerfile environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} depends_on: db: condition: service_healthy @@ -81,16 +91,14 @@ services: db: image: pgvector/pgvector:pg17 restart: always - ports: - - '${POSTGRES_PORT:-5432}:5432' environment: - - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_USER=${POSTGRES_USER:?set POSTGRES_USER in .env} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} - POSTGRES_DB=${POSTGRES_DB:-tradinggoose} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres'] + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:?set POSTGRES_USER in .env}'] interval: 5s timeout: 5s retries: 5 @@ -99,19 +107,9 @@ services: ollama: profiles: - gpu - image: ollama/ollama:latest - pull_policy: always + image: ollama/ollama:${OLLAMA_IMAGE_TAG:?set OLLAMA_IMAGE_TAG in .env} volumes: - ollama_data:/root/.ollama - ports: - - '11434:11434' - environment: - - NVIDIA_DRIVER_CAPABILITIES=all - - OLLAMA_LOAD_TIMEOUT=-1 - - OLLAMA_KEEP_ALIVE=-1 - - OLLAMA_DEBUG=1 - - OLLAMA_HOST=0.0.0.0:11434 - command: 'serve' deploy: resources: reservations: @@ -119,6 +117,12 @@ services: - driver: nvidia count: all capabilities: [gpu] + environment: + - NVIDIA_DRIVER_CAPABILITIES=all + - OLLAMA_LOAD_TIMEOUT=-1 + - OLLAMA_KEEP_ALIVE=-1 + - OLLAMA_HOST=0.0.0.0:11434 + command: 'serve' healthcheck: test: ['CMD', 'ollama', 'list'] interval: 10s @@ -127,20 +131,16 @@ services: start_period: 30s restart: unless-stopped - # Ollama CPU-only version (use with --profile cpu profile) + # Ollama CPU-only version (use with --profile cpu) ollama-cpu: profiles: - cpu - image: ollama/ollama:latest - pull_policy: always + image: ollama/ollama:${OLLAMA_IMAGE_TAG:?set OLLAMA_IMAGE_TAG in .env} volumes: - ollama_data:/root/.ollama - ports: - - '11434:11434' environment: - OLLAMA_LOAD_TIMEOUT=-1 - OLLAMA_KEEP_ALIVE=-1 - - OLLAMA_DEBUG=1 - OLLAMA_HOST=0.0.0.0:11434 command: 'serve' healthcheck: @@ -157,7 +157,7 @@ services: # Helper container to pull models automatically model-setup: - image: ollama/ollama:latest + image: ollama/ollama:${OLLAMA_IMAGE_TAG:?set OLLAMA_IMAGE_TAG in .env} profiles: - setup volumes: @@ -172,7 +172,7 @@ services: echo 'Pulling gemma3:4b model (recommended starter model)...' && ollama pull gemma3:4b && echo 'Model setup complete! You can now use gemma3:4b in TradingGoose.' && - echo 'To add more models, run: docker compose -f docker-compose.ollama.yml exec ollama ollama pull <model-name>' + echo 'To add more models, run: docker compose -f docker-compose.ollama.yml exec <ollama-service> ollama pull <model-name>' " restart: 'no' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7668bc1dd..fc0c62a5e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,25 +1,33 @@ services: + redis: + image: redis:7.2.1-alpine + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 5 tradinggoose: - image: ghcr.io/tradinggoose/tradinggoose:latest + image: ghcr.io/tradinggoose/tradinggoose:${IMAGE_TAG:?set IMAGE_TAG in .env} restart: unless-stopped ports: - '3000:3000' - deploy: - resources: - limits: - memory: 8G environment: - NODE_ENV=production - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} - - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here} - - COPILOT_API_KEY=${COPILOT_API_KEY} - - COPILOT_API_URL=${COPILOT_API_URL} + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} + - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env} + - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - REDIS_URL=redis://redis:6379 + - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:?set NEXT_PUBLIC_SOCKET_URL in .env} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} depends_on: + redis: + condition: service_healthy db: condition: service_healthy migrations: @@ -34,20 +42,22 @@ services: start_period: 10s realtime: - image: ghcr.io/tradinggoose/realtime:latest + image: ghcr.io/tradinggoose/realtime:${IMAGE_TAG:?set IMAGE_TAG in .env} restart: unless-stopped ports: - '3002:3002' - deploy: - resources: - limits: - memory: 4G environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} - - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} + - NODE_ENV=production + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} + - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env} + - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - REDIS_URL=redis://redis:6379 depends_on: + redis: + condition: service_healthy db: condition: service_healthy healthcheck: @@ -58,10 +68,10 @@ services: start_period: 10s migrations: - image: ghcr.io/tradinggoose/migrations:latest + image: ghcr.io/tradinggoose/migrations:${IMAGE_TAG:?set IMAGE_TAG in .env} working_dir: /app/packages/db environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} depends_on: db: condition: service_healthy @@ -71,16 +81,14 @@ services: db: image: pgvector/pgvector:pg17 restart: unless-stopped - ports: - - '${POSTGRES_PORT:-5432}:5432' environment: - - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_USER=${POSTGRES_USER:?set POSTGRES_USER in .env} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} - POSTGRES_DB=${POSTGRES_DB:-tradinggoose} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres'] + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:?set POSTGRES_USER in .env}'] interval: 5s timeout: 5s retries: 5 diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 004c55eca..1cf43edfb 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -1,7 +1,7 @@ # ======================================== # Base Stage: Alpine Linux with Bun # ======================================== -FROM oven/bun:1.2.22-alpine AS base +FROM oven/bun:1.3.11-alpine AS base # ======================================== # Dependencies Stage: Install Dependencies @@ -10,9 +10,6 @@ FROM base AS deps RUN apk add --no-cache libc6-compat WORKDIR /app -# Install turbo globally -RUN bun install -g turbo - COPY package.json bun.lock ./ RUN mkdir -p apps COPY apps/tradinggoose/package.json ./apps/tradinggoose/package.json @@ -26,7 +23,7 @@ FROM base AS builder WORKDIR /app # Install turbo globally in builder stage -RUN bun install -g turbo +RUN bun install -g turbo@2.5.8 COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -36,7 +33,7 @@ RUN bun install --omit dev --ignore-scripts # Required for standalone nextjs build WORKDIR /app/apps/tradinggoose -RUN bun install sharp +RUN bun install sharp@0.34.3 ENV NEXT_TELEMETRY_DISABLED=1 \ VERCEL_TELEMETRY_DISABLED=1 \ @@ -56,6 +53,19 @@ ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} RUN bun run build +# ======================================== +# Guardrails Stage: Build Presidio runtime +# ======================================== +FROM base AS guardrails +RUN apk add --no-cache python3 py3-pip bash +WORKDIR /app/lib/guardrails + +COPY apps/tradinggoose/lib/guardrails/setup.sh ./setup.sh +COPY apps/tradinggoose/lib/guardrails/requirements.txt ./requirements.txt +COPY apps/tradinggoose/lib/guardrails/validate_pii.py ./validate_pii.py + +RUN chmod +x ./setup.sh && ./setup.sh + # ======================================== # Runner Stage: Run the actual app # ======================================== @@ -63,8 +73,8 @@ RUN bun run build FROM base AS runner WORKDIR /app -# Install Python and dependencies for guardrails PII detection -RUN apk add --no-cache python3 py3-pip bash +# Install Python runtime for guardrails PII detection +RUN apk add --no-cache python3 ENV NODE_ENV=production @@ -75,21 +85,17 @@ RUN addgroup -g 1001 -S nodejs && \ COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/public ./apps/tradinggoose/public COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/.next/static ./apps/tradinggoose/.next/static +# Preserve Bun's workspace target for lib0 so yjs can resolve it at runtime. +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.bun/lib0@0.2.102/node_modules/lib0 ./node_modules/.bun/lib0@0.2.102/node_modules/lib0 +COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/node_modules/lib0 ./apps/tradinggoose/node_modules/lib0 -# Guardrails setup (files need to be owned by nextjs for runtime) -COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/lib/guardrails/setup.sh ./apps/tradinggoose/lib/guardrails/setup.sh -COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/lib/guardrails/requirements.txt ./apps/tradinggoose/lib/guardrails/requirements.txt -COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/lib/guardrails/validate_pii.py ./apps/tradinggoose/lib/guardrails/validate_pii.py - -# Run guardrails setup as root, then fix ownership of generated venv files -RUN chmod +x ./apps/tradinggoose/lib/guardrails/setup.sh && \ - cd ./apps/tradinggoose/lib/guardrails && \ - ./setup.sh && \ - chown -R nextjs:nodejs /app/apps/tradinggoose/lib/guardrails +# Guardrails runtime assets +COPY --from=guardrails --chown=nextjs:nodejs /app/lib/guardrails/venv ./lib/guardrails/venv +COPY --from=guardrails --chown=nextjs:nodejs /app/lib/guardrails/validate_pii.py ./lib/guardrails/validate_pii.py -# Create .next/cache directory with correct ownership +# Create the writable .next/cache directory for the non-root runtime user RUN mkdir -p apps/tradinggoose/.next/cache && \ - chown -R nextjs:nodejs /app + chown nextjs:nodejs apps/tradinggoose/.next/cache # Switch to non-root user USER nextjs @@ -98,4 +104,4 @@ EXPOSE 3000 ENV PORT=3000 \ HOSTNAME="0.0.0.0" -CMD ["bun", "apps/tradinggoose/server.js"] \ No newline at end of file +CMD ["bun", "apps/tradinggoose/server.js"] diff --git a/docker/db.Dockerfile b/docker/db.Dockerfile index 32c8f3add..2f951ac9f 100644 --- a/docker/db.Dockerfile +++ b/docker/db.Dockerfile @@ -1,11 +1,11 @@ # ======================================== # Dependencies Stage: Install Dependencies # ======================================== -FROM oven/bun:1.2.22-alpine AS deps +FROM oven/bun:1.3.11-alpine AS deps WORKDIR /app # Copy only package files needed for migrations -COPY package.json bun.lock turbo.json ./ +COPY package.json bun.lock ./ COPY packages/db/package.json ./packages/db/package.json # Install dependencies @@ -14,19 +14,23 @@ RUN bun install --ignore-scripts # ======================================== # Runner Stage: Production Environment # ======================================== -FROM oven/bun:1.2.22-alpine AS runner +FROM oven/bun:1.3.11-alpine AS runner WORKDIR /app # Create non-root user and group RUN addgroup -g 1001 -S nodejs && \ adduser -S nextjs -u 1001 -# Copy only the necessary files from deps +# Copy only the migration inputs and runtime files from the db package COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules +COPY --chown=nextjs:nodejs packages/db/package.json ./packages/db/package.json COPY --chown=nextjs:nodejs packages/db/drizzle.config.ts ./packages/db/drizzle.config.ts -COPY --chown=nextjs:nodejs packages/db ./packages/db +COPY --chown=nextjs:nodejs packages/db/schema.ts ./packages/db/schema.ts +COPY --chown=nextjs:nodejs packages/db/consts.ts ./packages/db/consts.ts +COPY --chown=nextjs:nodejs packages/db/schema ./packages/db/schema +COPY --chown=nextjs:nodejs packages/db/migrations ./packages/db/migrations # Switch to non-root user USER nextjs -WORKDIR /app/packages/db \ No newline at end of file +WORKDIR /app/packages/db diff --git a/docker/realtime.Dockerfile b/docker/realtime.Dockerfile index d0db81139..4ff1832d6 100644 --- a/docker/realtime.Dockerfile +++ b/docker/realtime.Dockerfile @@ -1,7 +1,7 @@ # ======================================== # Base Stage: Alpine Linux with Bun # ======================================== -FROM oven/bun:1.2.22-alpine AS base +FROM oven/bun:1.3.11-alpine AS base # ======================================== # Dependencies Stage: Install Dependencies @@ -10,9 +10,6 @@ FROM base AS deps RUN apk add --no-cache libc6-compat WORKDIR /app -# Install turbo globally -RUN bun install -g turbo - COPY package.json bun.lock ./ RUN mkdir -p apps COPY apps/tradinggoose/package.json ./apps/tradinggoose/package.json @@ -28,11 +25,17 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +RUN bun install --omit dev --ignore-scripts + +WORKDIR /app/apps/tradinggoose +RUN mkdir -p /tmp/realtime-build && \ + bun build --target bun --outfile /tmp/realtime-build/socket-server.js socket-server/index.ts + # ======================================== # Runner Stage: Run the Socket Server # ======================================== FROM base AS runner -WORKDIR /app +WORKDIR /app/apps/tradinggoose ENV NODE_ENV=production @@ -40,11 +43,8 @@ ENV NODE_ENV=production RUN addgroup -g 1001 -S nodejs && \ adduser -S nextjs -u 1001 -# Copy the tradinggoose app and the shared db package needed by socket-server -COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose ./apps/tradinggoose -COPY --from=builder --chown=nextjs:nodejs /app/packages/db ./packages/db -COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules -COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json +# Copy the bundled socket server runtime artifact only +COPY --from=builder --chown=nextjs:nodejs /tmp/realtime-build/socket-server.js ./socket-server.js # Switch to non-root user USER nextjs @@ -55,5 +55,5 @@ ENV PORT=3002 \ SOCKET_PORT=3002 \ HOSTNAME="0.0.0.0" -# Run the socket server directly -CMD ["bun", "apps/tradinggoose/socket-server/index.ts"] \ No newline at end of file +# Run the bundled socket server directly +CMD ["bun", "./socket-server.js"]