From fccc53efd0a4b00107b8292d55d15355a8d60a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 15 May 2026 14:55:18 +0200 Subject: [PATCH 1/8] Use GITHUB_TOKEN for bump release checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Juan José Mata --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b7b74e37f..e4a6c02e9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -429,7 +429,7 @@ jobs: uses: actions/checkout@v4.2.0 with: ref: ${{ steps.source_branch.outputs.branch }} - token: ${{ secrets.GH_PAT || github.token }} + token: ${{ github.token }} - name: Bump pre-release version run: | From 495d8a223d726f966911ebea15e0c95d1e708c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 15 May 2026 14:57:40 +0200 Subject: [PATCH 2/8] Bump failed --- .sure-version | 2 +- charts/sure/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.sure-version b/.sure-version index 81fe56438..11a626600 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.1-alpha.7 +0.7.1-alpha.8 diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index b9e521afb..fa061c2c9 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.7.1-alpha.7 -appVersion: "0.7.1-alpha.7" +version: 0.7.1-alpha.8 +appVersion: "0.7.1-alpha.8" kubeVersion: ">=1.25.0-0" From 6a765a90c63ac7376a9e5436d537eeb6b8a588d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 15 May 2026 23:14:20 +0200 Subject: [PATCH 3/8] chore: GitHub workflow to auto-deploy PRs to Cloudflare (#880) * feat: add Cloudflare Containers PR preview deployments Add GitHub workflows to automatically deploy PRs to Cloudflare Containers after tests pass, with automatic cleanup after 24 hours. Components: - workers/preview/: Cloudflare Worker entry point that routes traffic to the Rails container - preview-deploy.yml: Deploys PRs after CI passes, comments preview URL on PR - preview-cleanup.yml: Cleans up previews on PR close or after 24 hours via scheduled job The container sleeps after 30 minutes of inactivity and wakes automatically on the next request. Required secrets: - CLOUDFLARE_API_TOKEN - CLOUDFLARE_ACCOUNT_ID - CLOUDFLARE_WORKERS_SUBDOMAIN https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix: use development environment with embedded PostgreSQL for previews - Add preview-specific Dockerfile with PostgreSQL server included - Add docker-entrypoint.sh to start PostgreSQL and run migrations - Change RAILS_ENV from production to development - Auto-generate SECRET_KEY_BASE and DATABASE_URL for self-contained previews https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * feat: add Redis to preview container - Install redis-server in the preview Dockerfile - Start Redis in the entrypoint before PostgreSQL - Auto-configure REDIS_URL for Sidekiq background jobs https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix: mark GitHub deployment inactive on manual PR cleanup When using workflow_dispatch with a specific pr_number, the workflow now also marks the associated GitHub deployment as inactive, mirroring the behavior of the batch cleanup path. https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix: remove npm cache config that requires missing lockfile The setup-node action's cache feature requires a package-lock.json which doesn't exist in workers/preview/. Remove the cache configuration to fix the workflow. https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix: only update deployment status when deployment ID exists Add condition to check steps.deployment.outputs.result exists before attempting to update deployment status. This prevents a JavaScript syntax error when the deployment step fails and no ID is available. https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix: quote shell variables to fix SC2086 shellcheck warning Quote the --var argument and GITHUB_OUTPUT redirection to prevent word splitting issues. https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix: add permissions for deployment status operations Add deployments: write permission to the cleanup workflow so the GITHUB_TOKEN can list and update deployment statuses. https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix: specify build context for Dockerfile in wrangler config Use object syntax for image config to set build context to repository root, allowing the Dockerfile to reference files from both the root (Gemfile, .ruby-version) and workers/preview/ (docker-entrypoint.sh). https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix: run wrangler from repo root for correct build context - Update workflow to run wrangler with --config flag from repo root - Update wrangler.toml paths (main, image) to be relative to repo root - Embed entrypoint script directly in Dockerfile using heredoc - Remove separate docker-entrypoint.sh file This ensures the Docker build context includes Gemfile, .ruby-version, and other files at the repo root. https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix: move preview Dockerfile to repo root for correct build context Wrangler resolves paths relative to the config file, not the current directory. Moving Dockerfile.preview to repo root ensures: - Build context is the repo root (where Gemfile, .ruby-version are) - Path in wrangler.toml is ../../Dockerfile.preview (relative to config) - Worker runs from workers/preview/ directory again https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix: use find to locate pg_hba.conf instead of glob in redirection Shell glob patterns don't work with redirection operators. Use find to locate the actual pg_hba.conf path before writing to it. https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix: enable workers_dev for preview deployments Add workers_dev = true to make the preview worker accessible via the workers.dev subdomain. https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * feat: enable observability for container logs https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP * fix preview container boot path * fix: set preview container startup command explicitly * fix: update preview worker compatibility date * chore: expose preview container diagnostics * fix: recover from stale preview container state * fix: harden preview container startup paths * chore: report preview startup stages * fix: bypass stale container helper state during recovery * fix: allow longer preview container startup * fix: upgrade preview container runtime * fix: use supported node version for preview deploy * fix: use public container startup flow * fix: simplify preview container startup * chore: retain preview container diagnostic history * fix: bypass systemctl redirect for postgres startup * chore: probe rails readiness from inside preview container * chore: capture rails process and port diagnostics * chore: capture rails startup logs on preview timeout * fix: align preview bind behavior with ipv6 startup model * chore: capture preview socket state on rails timeout * chore: capture rails wait state and child processes * fix: launch preview with puma directly * fix: run preview in production mode * chore: probe preview app boot before puma * fix: disable lookbook routes in production preview * chore: capture ruby backtrace from hung boot probe * fix: disable bootsnap in preview runtime * fix: disable sidekiq web routes in production preview * chore: trace hung preview boot probe with strace * fix: json-escape preview telemetry payloads * fix: pass preview telemetry env vars correctly * chore: signal ruby child for preview boot backtrace * fix: allow longer preview cold-start budget * fix: skip sidekiq web requires in production preview * chore: deploy hello world preview container * fix(preview): restore rails image without redundant warmup * feat(preview): seed demo dataset on boot * ci(preview): require preview-cf label * ci(preview): reuse pr workflow checks * fix(preview): avoid clearing demo data in production boot * fix(preview): tolerate already-running postgres on boot * fix(preview): check demo user via psql during boot * fix(preview): defer heavy demo seed until after boot * fix(preview): move demo-user creation after rails boot * fix(preview): fail fast on container lifecycle errors * fix(preview): validate manual cleanup pr input * fix(preview): parameterize preview pr number * ci(preview): use setup-node v6 --------- Co-authored-by: Claude Co-authored-by: KiloClaw --- .github/workflows/preview-cleanup.yml | 196 +++ .github/workflows/preview-deploy.yml | 182 +++ Dockerfile.preview | 247 ++++ config/routes.rb | 10 +- workers/preview/package-lock.json | 1583 +++++++++++++++++++++++++ workers/preview/package.json | 15 + workers/preview/src/index.ts | 132 +++ workers/preview/tsconfig.json | 13 + workers/preview/wrangler.toml | 40 + 9 files changed, 2414 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/preview-cleanup.yml create mode 100644 .github/workflows/preview-deploy.yml create mode 100644 Dockerfile.preview create mode 100644 workers/preview/package-lock.json create mode 100644 workers/preview/package.json create mode 100644 workers/preview/src/index.ts create mode 100644 workers/preview/tsconfig.json create mode 100644 workers/preview/wrangler.toml diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml new file mode 100644 index 000000000..6c9cbf753 --- /dev/null +++ b/.github/workflows/preview-cleanup.yml @@ -0,0 +1,196 @@ +name: Cleanup PR Previews + +on: + # Run hourly to check for expired previews + schedule: + - cron: '0 * * * *' + + # Immediately cleanup when PR is closed + pull_request: + types: [closed, unlabeled] + + # Allow manual trigger + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to cleanup (optional, cleans all expired if empty)' + required: false + type: string + +permissions: + contents: read + deployments: write + +jobs: + cleanup-on-close: + name: Cleanup closed PR preview + if: github.event_name == 'pull_request' && (github.event.action == 'closed' || (github.event.action == 'unlabeled' && github.event.label.name == 'preview-cf')) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Wrangler + run: npm install -g wrangler + + - name: Delete preview Worker + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + WORKER_NAME="sure-preview-${{ github.event.pull_request.number }}" + echo "Deleting Worker: $WORKER_NAME" + + # Delete the worker (this also stops any running containers) + wrangler delete --name "$WORKER_NAME" --force || echo "Worker may not exist" + + - name: Delete GitHub Deployment + uses: actions/github-script@v7 + with: + script: | + const environment = `preview-pr-${{ github.event.pull_request.number }}`; + const description = context.payload.action === 'closed' + ? 'PR closed - preview deleted' + : 'preview-cf label removed - preview deleted'; + + try { + // Get deployments for this environment + const { data: deployments } = await github.rest.repos.listDeployments({ + owner: context.repo.owner, + repo: context.repo.repo, + environment: environment + }); + + // Mark all deployments as inactive + for (const deployment of deployments) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.id, + state: 'inactive', + description + }); + } + + console.log(`Marked ${deployments.length} deployments as inactive`); + } catch (error) { + console.log('No deployments to cleanup or error:', error.message); + } + + cleanup-expired: + name: Cleanup expired previews + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Wrangler + run: npm install -g wrangler + + - name: Cleanup expired previews + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_INPUT: ${{ inputs.pr_number }} + run: | + # If specific PR number provided, only cleanup that one + if [ -n "$PR_INPUT" ]; then + if [[ "$PR_INPUT" =~ ^[1-9][0-9]*$ ]]; then + PR_NUM="$PR_INPUT" + WORKER_NAME="sure-preview-$PR_NUM" + echo "Manually deleting Worker: $WORKER_NAME" + wrangler delete --name "$WORKER_NAME" --force || echo "Worker may not exist" + + # Cleanup GitHub deployment for this PR + echo "Cleaning up GitHub deployment for PR #$PR_NUM" + gh api \ + -X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \ + --jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do + if [ -n "$DEPLOY_ID" ]; then + gh api \ + -X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \ + -f state=inactive \ + -f description="Preview manually deleted" || true + fi + done || echo "No deployments to cleanup or error occurred" + else + echo "Invalid PR number input '$PR_INPUT'; skipping manual cleanup" + fi + + exit 0 + fi + + # Get list of all preview workers + echo "Fetching list of preview workers..." + + # Use Cloudflare API to list workers + WORKERS=$(curl -s -X GET \ + "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \ + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + -H "Content-Type: application/json" | jq -r '.result[] | select(.id | startswith("sure-preview-")) | .id') + + if [ -z "$WORKERS" ]; then + echo "No preview workers found" + exit 0 + fi + + echo "Found preview workers:" + echo "$WORKERS" + + # Check each worker's deployment time + CUTOFF_TIME=$(date -d '24 hours ago' +%s) + + for WORKER in $WORKERS; do + echo "Checking $WORKER..." + + # Get worker details to find last deployment time + WORKER_INFO=$(curl -s -X GET \ + "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/$WORKER" \ + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + -H "Content-Type: application/json") + + MODIFIED_ON=$(echo "$WORKER_INFO" | jq -r '.result.modified_on // empty') + + if [ -n "$MODIFIED_ON" ]; then + MODIFIED_TS=$(date -d "$MODIFIED_ON" +%s 2>/dev/null || echo "0") + + if [ "$MODIFIED_TS" -lt "$CUTOFF_TIME" ]; then + echo "Worker $WORKER is older than 24 hours, deleting..." + wrangler delete --name "$WORKER" --force || echo "Failed to delete $WORKER" + + # Extract PR number and cleanup GitHub deployment + PR_NUM=$(echo "$WORKER" | sed 's/sure-preview-//') + if [ -n "$PR_NUM" ]; then + echo "Cleaning up GitHub deployment for PR #$PR_NUM" + gh api \ + -X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \ + --jq '.[].id' | while read -r DEPLOY_ID; do + gh api \ + -X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \ + -f state=inactive \ + -f description="Preview expired after 24 hours" || true + done + fi + else + echo "Worker $WORKER is still within 24-hour window, keeping..." + fi + fi + done + + echo "Cleanup complete" diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml new file mode 100644 index 000000000..9d89ff413 --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,182 @@ +name: Deploy PR Preview + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + paths-ignore: + - 'charts/**' + - 'docs/**' + - '*.md' + +jobs: + deploy-preview: + if: contains(github.event.pull_request.labels.*.name, 'preview-cf') + name: Deploy to Cloudflare Containers + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: read + pull-requests: write + deployments: write + + steps: + - name: Wait for PR CI to pass + uses: actions/github-script@v7 + with: + script: | + const headSha = context.payload.pull_request.head.sha; + const timeoutMs = 10 * 60 * 1000; + const pollMs = 15 * 1000; + const startedAt = Date.now(); + let lastState = 'not found'; + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + while (Date.now() - startedAt < timeoutMs) { + const { data } = await github.rest.actions.listWorkflowRunsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + event: 'pull_request', + head_sha: headSha, + per_page: 20, + }); + + const prRun = data.workflow_runs.find((run) => run.name === 'Pull Request' && run.head_sha === headSha); + + if (prRun) { + lastState = `${prRun.status}/${prRun.conclusion ?? 'pending'}`; + core.info(`Pull Request workflow ${prRun.id}: ${lastState}`); + + if (prRun.status === 'completed') { + if (prRun.conclusion === 'success') { + return; + } + + core.setFailed(`Pull Request workflow concluded with ${prRun.conclusion}`); + return; + } + } + + await sleep(pollMs); + } + + core.setFailed(`Timed out waiting for Pull Request workflow for ${headSha}. Last state: ${lastState}`); + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install Wrangler dependencies + working-directory: workers/preview + run: npm install + + - name: Configure preview files for this PR + working-directory: workers/preview + run: | + sed -i "s/\${PR_NUMBER}/${{ github.event.pull_request.number }}/g" wrangler.toml + sed -i "s/\${PR_NUMBER}/${{ github.event.pull_request.number }}/g" src/index.ts + cat wrangler.toml + + - name: Create GitHub Deployment + id: deployment + uses: actions/github-script@v7 + with: + script: | + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.payload.pull_request.head.sha, + environment: `preview-pr-${{ github.event.pull_request.number }}`, + auto_merge: false, + required_contexts: [], + description: 'PR Preview Deployment' + }); + return deployment.data.id; + result-encoding: string + + - name: Deploy to Cloudflare Containers + id: deploy + working-directory: workers/preview + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + npx wrangler deploy --var "PR_NUMBER:${{ github.event.pull_request.number }}" + + # Get the deployment URL + PREVIEW_URL="https://sure-preview-${{ github.event.pull_request.number }}.${{ secrets.CLOUDFLARE_WORKERS_SUBDOMAIN }}.workers.dev" + echo "preview_url=${PREVIEW_URL}" >> "$GITHUB_OUTPUT" + + - name: Update Deployment Status + if: always() && steps.deployment.outputs.result + uses: actions/github-script@v7 + with: + script: | + const state = '${{ job.status }}' === 'success' ? 'success' : 'failure'; + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: ${{ steps.deployment.outputs.result }}, + state: state, + environment_url: state === 'success' ? '${{ steps.deploy.outputs.preview_url }}' : undefined, + description: state === 'success' ? 'Preview deployed successfully' : 'Preview deployment failed' + }); + + - name: Comment on PR + if: success() + uses: actions/github-script@v7 + with: + script: | + const previewUrl = '${{ steps.deploy.outputs.preview_url }}'; + const commentBody = `## 🚀 Preview Deployment Ready + + Your preview environment has been deployed to Cloudflare Containers. + + **Preview URL:** ${previewUrl} + + > ⏰ This preview is intended to be cleaned up after **24 hours** of the last deployment once the cleanup workflow is live on the default branch. + > 💤 The container will sleep after 30 minutes of inactivity and wake on the next request. + + --- + Deployed from commit ${{ github.event.pull_request.head.sha }}`; + + // Find existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ github.event.pull_request.number }} + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Preview Deployment Ready') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: commentBody + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ github.event.pull_request.number }}, + body: commentBody + }); + } + - name: Store cleanup metadata + if: success() + uses: actions/upload-artifact@v4 + with: + name: preview-cleanup-pr-${{ github.event.pull_request.number }} + path: | + workers/preview/wrangler.toml + retention-days: 2 diff --git a/Dockerfile.preview b/Dockerfile.preview new file mode 100644 index 000000000..3b687b964 --- /dev/null +++ b/Dockerfile.preview @@ -0,0 +1,247 @@ +# syntax = docker/dockerfile:1 + +# Preview Dockerfile for Cloudflare Containers +# Includes PostgreSQL and Redis for self-contained development testing + +ARG RUBY_VERSION=3.4.7 +FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base + +WORKDIR /rails + +# Install base packages including PostgreSQL and Redis servers +RUN apt-get update -qq \ + && apt-get install --no-install-recommends -y \ + curl libvips postgresql postgresql-client redis-server libyaml-0-2 procps sudo openssl strace \ + && rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set development environment +ARG BUILD_COMMIT_SHA +ENV RAILS_ENV="development" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA} + +# Build stage +FROM base AS build + +RUN apt-get update -qq \ + && apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev \ + && rm -rf /var/lib/apt/lists /var/cache/apt/archives + +COPY .ruby-version Gemfile Gemfile.lock ./ +RUN bundle install \ + && rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git \ + && bundle exec bootsnap precompile --gemfile -j 0 + +COPY . . + +RUN bundle exec bootsnap precompile -j 0 app/ lib/ + +# Precompile assets +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + +# Final stage +FROM base + +# Create rails user and configure PostgreSQL/Redis permissions +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + echo "rails ALL=(ALL) NOPASSWD: /usr/bin/pg_ctlcluster, /usr/bin/redis-server" > /etc/sudoers.d/rails && \ + chmod 0440 /etc/sudoers.d/rails + +# Configure PostgreSQL to allow local connections +RUN PG_HBA=$(find /etc/postgresql -name pg_hba.conf 2>/dev/null | head -1) && \ + if [ -n "$PG_HBA" ]; then \ + echo "local all all trust" > "$PG_HBA" && \ + echo "host all all 127.0.0.1/32 trust" >> "$PG_HBA" && \ + echo "host all all ::1/128 trust" >> "$PG_HBA"; \ + fi + +# Create database directory with correct permissions +RUN mkdir -p /var/run/postgresql && \ + chown -R postgres:postgres /var/run/postgresql && \ + chmod 2775 /var/run/postgresql + +# Copy built artifacts +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +# Create preview entrypoint script inline +RUN cat > /rails/bin/preview-entrypoint << 'ENTRYPOINT_EOF' +#!/bin/bash +set -e + +cd /rails + +emit_status() { + if [ -n "$PREVIEW_ORIGIN" ]; then + local stage="$1" + local detail="$2" + local payload + payload=$(STAGE="$stage" DETAIL="$detail" ruby -rjson -e 'print JSON.generate({stage: ENV.fetch("STAGE"), detail: ENV.fetch("DETAIL", "")})' 2>/dev/null) || return 0 + curl -fsS -X POST "$PREVIEW_ORIGIN/_container_event" \ + -H 'content-type: application/json' \ + --data "$payload" >/dev/null || true + fi +} + +trap 'emit_status failed "preview-entrypoint failed on line ${LINENO}"' ERR +emit_status boot "preview-entrypoint started" + +REDIS_READY=0 +POSTGRES_READY=0 + +# Start Redis +echo "Starting Redis..." +emit_status redis-start "starting redis" +sudo redis-server --daemonize yes --bind 127.0.0.1 + +# Wait for Redis to be ready +echo "Waiting for Redis to be ready..." +for i in {1..10}; do + if redis-cli ping > /dev/null 2>&1; then + echo "Redis is ready" + emit_status redis-ready "redis is ready" + REDIS_READY=1 + break + fi + sleep 1 +done + +if [ "$REDIS_READY" -ne 1 ]; then + echo "Redis did not become ready in time" + exit 1 +fi + +# Start PostgreSQL +echo "Starting PostgreSQL..." +emit_status postgres-start "starting postgres" +PG_VERSION=$(ls /etc/postgresql/ | sort -V | tail -1) +if [ -z "$PG_VERSION" ]; then + echo "Could not determine installed PostgreSQL version" + exit 1 +fi +if sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main status > /dev/null 2>&1; then + emit_status postgres-already-running "postgres cluster already running" +else + sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main start +fi + +# Wait for PostgreSQL to be ready +echo "Waiting for PostgreSQL to be ready..." +for i in {1..30}; do + if pg_isready -h localhost -U postgres > /dev/null 2>&1; then + echo "PostgreSQL is ready" + emit_status postgres-ready "postgres is ready" + POSTGRES_READY=1 + break + fi + sleep 1 +done + +if [ "$POSTGRES_READY" -ne 1 ]; then + echo "PostgreSQL did not become ready in time" + exit 1 +fi + +# Create database user and database if they don't exist +echo "Setting up database..." +emit_status db-setup "setting up database" +psql -h localhost -U postgres -tc "SELECT 1 FROM pg_roles WHERE rolname='rails'" | grep -q 1 || \ + psql -h localhost -U postgres -c "CREATE USER rails WITH SUPERUSER PASSWORD 'rails';" + +psql -h localhost -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='sure_development'" | grep -q 1 || \ + psql -h localhost -U postgres -c "CREATE DATABASE sure_development OWNER rails;" + +# Set DATABASE_URL if not already set +export DATABASE_URL="${DATABASE_URL:-postgres://rails:rails@localhost:5432/sure_development}" + +# Set REDIS_URL if not already set +export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" + +# Generate SECRET_KEY_BASE if not set +export SECRET_KEY_BASE="${SECRET_KEY_BASE:-$(openssl rand -hex 64)}" + +# Run database migrations +echo "Running database migrations..." +emit_status db-prepare "running rails db:prepare" +/rails/bin/rails db:prepare +emit_status db-prepare-done "rails db:prepare finished" + +# Defer all demo-data creation until after Rails is up so preview can boot first +echo "Checking demo dataset..." +emit_status demo-data-check "checking for default demo user" +DEMO_EMAIL="${DEMO_USER_EMAIL:-user@example.com}" +DEMO_EMAIL_SQL=${DEMO_EMAIL//\'/\'\'} +DEMO_SEED="${DEMO_DATA_SEED:-880}" +DEMO_HAS_USER=0 +DEMO_HAS_DATA=0 + +if psql "$DATABASE_URL" -tAc "SELECT 1 FROM users WHERE email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then + DEMO_HAS_USER=1 + emit_status demo-data-user-present "default demo user already exists" +fi + +if psql "$DATABASE_URL" -tAc "SELECT 1 FROM accounts a JOIN users u ON u.family_id = a.family_id WHERE u.email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then + DEMO_HAS_DATA=1 + emit_status demo-data-skip "demo financial data already exists" +else + emit_status demo-data-deferred "deferring demo data creation until after rails boot" +fi + +# Execute the main command with an internal readiness probe +echo "Starting Rails server..." +emit_status rails-start "starting rails server" +"$@" > /tmp/rails.log 2>&1 & +RAILS_PID=$! + +for i in {1..180}; do + if curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then + emit_status rails-up-ready "rails responded on localhost:3000/up" + + if [ "$DEMO_HAS_USER" -ne 1 ] || [ "$DEMO_HAS_DATA" -ne 1 ]; then + emit_status demo-data-load "creating/backfilling demo dataset in background (seed=${DEMO_SEED})" + ( + ( + DEMO_USER_EMAIL="$DEMO_EMAIL" DEMO_DATA_SEED="$DEMO_SEED" /rails/bin/rails runner ' + email = ENV.fetch("DEMO_USER_EMAIL") + generator = Demo::Generator.new(seed: ENV.fetch("DEMO_DATA_SEED")) + user = User.find_by(email: email) + + unless user + generator.generate_empty_data!(skip_clear: true) + user = User.find_by!(email: email) + end + + has_accounts = user.family.accounts.exists? + generator.generate_new_user_data_for!(user.family, email: user.email) unless has_accounts + ' + ) > /tmp/demo-data.log 2>&1 && \ + emit_status demo-data-ready "default demo dataset loaded in background" || \ + emit_status demo-data-failed "background demo dataset load failed" + ) & + fi + + break + fi + sleep 1 +done + +if ! curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then + emit_status rails-up-timeout "rails did not answer localhost:3000/up in time" + emit_status rails-process-status "$(ps -o pid=,ppid=,stat=,comm=,args= -p "$RAILS_PID" 2>/dev/null | tr -s ' ' | sed 's/^ //')" + emit_status rails-process-wchan "$(cat /proc/$RAILS_PID/wchan 2>/dev/null | tr '\n' ' ' | cut -c 1-200)" + emit_status rails-process-children "$(ps -o pid=,ppid=,stat=,comm=,args= --ppid "$RAILS_PID" 2>/dev/null | tail -n +2 | tr '\n' '|' | cut -c 1-600)" + emit_status rails-socket-state "$(ruby -e 'hex="0BB8"; rows=File.readlines("/proc/net/tcp")+File.readlines("/proc/net/tcp6"); hits=rows.select{|l| l.include?(":#{hex} ")}.map{|l| l.strip.split[3] rescue nil}.compact; puts(hits.empty? ? "no-listener" : hits.join(","))' 2>&1 | tr '\n' ' ' | cut -c 1-400)" + emit_status rails-log-tail "$(tail -n 40 /tmp/rails.log 2>&1 | sed 's/"/'"'"'/g' | tr '\n' ' ' | cut -c 1-1200)" +fi + +wait "$RAILS_PID" +ENTRYPOINT_EOF +RUN chmod 755 /rails/bin/preview-entrypoint && chown rails:rails /rails/bin/preview-entrypoint + +USER 1000:1000 + +ENTRYPOINT ["/rails/bin/preview-entrypoint"] + +EXPOSE 3000 +CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"] diff --git a/config/routes.rb b/config/routes.rb index 5f4aa337a..02859c9b7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,7 @@ -require "sidekiq/web" -require "sidekiq/cron/web" +unless Rails.env.production? + require "sidekiq/web" + require "sidekiq/cron/web" +end Rails.application.routes.draw do resources :indexa_capital_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do @@ -168,7 +170,7 @@ Rails.application.routes.draw do delete :disable end - mount Lookbook::Engine, at: "/design-system" + mount Lookbook::Engine, at: "/design-system" unless Rails.env.production? if Rails.env.development? mount Rswag::Api::Engine => "/api-docs" @@ -176,7 +178,7 @@ Rails.application.routes.draw do end # Uses basic auth - see config/initializers/sidekiq.rb - mount Sidekiq::Web => "/sidekiq" + mount Sidekiq::Web => "/sidekiq" unless Rails.env.production? # AI chats resources :chats do diff --git a/workers/preview/package-lock.json b/workers/preview/package-lock.json new file mode 100644 index 000000000..2cfb46528 --- /dev/null +++ b/workers/preview/package-lock.json @@ -0,0 +1,1583 @@ +{ + "name": "sure-preview-worker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sure-preview-worker", + "version": "1.0.0", + "devDependencies": { + "@cloudflare/containers": "^0.3.3", + "@cloudflare/workers-types": "^4.20250124.0", + "typescript": "^5.0.0", + "wrangler": "^4.0.0" + } + }, + "node_modules/@cloudflare/containers": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.3.3.tgz", + "integrity": "sha512-ZSXmArCoo5bVTp8pGAJdl5WKmwtZDcffJqr4JcZEbSmMIFjU+AlBqgysuxXMgu03Rp239cOdqerbjK7H0K2krQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260507.1.tgz", + "integrity": "sha512-S85aMwcaPJUjKWDiG6iMMnioKWtPLACa6m0j/EhHR1GYfVpnxb974cBc6d25L+sf7jHWHJI2u5hGp0UTJ7MtXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260507.1.tgz", + "integrity": "sha512-GMEBu8Zp9Q97HLnf7bWJN4KjWpN5MxpeqdvHjBGWNl8UYprJI0k+Jkp89+Wh5S8vIon+HoVbDfOzPa7VwgL6Eg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260507.1.tgz", + "integrity": "sha512-QlrKEBdgA3uVc0Ok0Q3+0/CW0CTjgj5ySir1i1YY5FXVv0X6GpwtnB5umjunjF2MFprss+L+iFGZzxcSvMC1nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260507.1.tgz", + "integrity": "sha512-eGbbupEtK2nh9V9Dhcx3vv3GTKeXqSVNgAEYVCCN0NGS9tl9HbMoHRX/4JL181FKXROMigWBCQVL//qPhsAzBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260507.1.tgz", + "integrity": "sha512-dmClJ/E0BAcuDetQIZFqbeAXejWrG5pysGRMQ6T83Y0IW/7IAamY2zFEkAJ10I5xwZsdHuYsZtzlOxpEXpJs7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260511.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260511.1.tgz", + "integrity": "sha512-FA+si7cOq9i/gtCHhIc0XJL0l1F/ApF+m00752Aj7WZFJrj3ZulT2T8/+rT3BabMT0QEnqFEGIqCgrmqhgEfMg==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260507.1", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260507.1.tgz", + "integrity": "sha512-PSXBiLExTdZ4UGO/raKCHQauUpYL7F880ZRB7j0+78Rv8h7TsdN2E/iEDK9sK2Y+SPQ5wJSeAa+rDeVKoZZoEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260507.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260507.1.tgz", + "integrity": "sha512-z7JhsFSe6+X1b5fUHaVpo15VM1IRMJiLofEkq8iKdCo+Veqc+FUg5lIsuz8NwePxuSKrXtO4ZQpGkQLbPVXFhg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260507.1", + "@cloudflare/workerd-darwin-arm64": "1.20260507.1", + "@cloudflare/workerd-linux-64": "1.20260507.1", + "@cloudflare/workerd-linux-arm64": "1.20260507.1", + "@cloudflare/workerd-windows-64": "1.20260507.1" + } + }, + "node_modules/wrangler": { + "version": "4.90.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.90.0.tgz", + "integrity": "sha512-bmNIykl59TfCUn5xQgU7IWylSsPx3LQaPLMSAq2VQHt89CBrcj9qXQ0eYfjBCWA5XTBVgten391evt7xxtXwcA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260507.1", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260507.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260507.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/workers/preview/package.json b/workers/preview/package.json new file mode 100644 index 000000000..5823dd4ba --- /dev/null +++ b/workers/preview/package.json @@ -0,0 +1,15 @@ +{ + "name": "sure-preview-worker", + "version": "1.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev" + }, + "devDependencies": { + "@cloudflare/containers": "^0.3.3", + "@cloudflare/workers-types": "^4.20250124.0", + "typescript": "^5.0.0", + "wrangler": "^4.0.0" + } +} diff --git a/workers/preview/src/index.ts b/workers/preview/src/index.ts new file mode 100644 index 000000000..becb8fbe7 --- /dev/null +++ b/workers/preview/src/index.ts @@ -0,0 +1,132 @@ +import { Container } from "@cloudflare/containers"; + +interface Env { + RAILS_CONTAINER: DurableObjectNamespace; +} + +const DIAGNOSTICS_KEY = "preview-diagnostics"; +const DIAGNOSTICS_HISTORY_KEY = "preview-diagnostics-history"; + +export class RailsContainer extends Container { + defaultPort = 3000; + pingEndpoint = "container/up"; + entrypoint = ["/rails/bin/preview-entrypoint", "bundle", "exec", "puma", "-C", "config/puma.rb"]; + envVars = { + RAILS_ENV: "production", + RAILS_LOG_TO_STDOUT: "true", + RAILS_SERVE_STATIC_FILES: "true", + SECRET_KEY_BASE: "preview-secret-key-base-for-pr-${PR_NUMBER}", + APP_DOMAIN: "sure-preview-${PR_NUMBER}.sure-finances.workers.dev", + APP_URL: "https://sure-preview-${PR_NUMBER}.sure-finances.workers.dev", + RAILS_FORCE_SSL: "false", + RAILS_ASSUME_SSL: "false", + ACTIVE_STORAGE_SERVICE: "local", + DISABLE_BOOTSNAP: "1", + BINDING: "::", + DEMO_DATA_SEED: "${PR_NUMBER}", + PREVIEW_ORIGIN: "https://sure-preview-${PR_NUMBER}.sure-finances.workers.dev", + }; + sleepAfter = "30m"; + enableInternet = true; + + get runtimeContainer() { + return this.ctx.container!; + } + + async recordDiagnostic(payload: Record): Promise { + const diagnostic = { + ...payload, + state: await this.getState(), + }; + + await this.ctx.storage.put(DIAGNOSTICS_KEY, diagnostic); + + const history = + ((await this.ctx.storage.get(DIAGNOSTICS_HISTORY_KEY)) as Record[] | undefined) ?? []; + + history.push(diagnostic); + + if (history.length > 20) { + history.splice(0, history.length - 20); + } + + await this.ctx.storage.put(DIAGNOSTICS_HISTORY_KEY, history); + } + + override async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === "/_container_status") { + return Response.json({ + state: await this.getState(), + containerRunning: this.runtimeContainer.running, + diagnostics: (await this.ctx.storage.get(DIAGNOSTICS_KEY)) ?? null, + diagnosticsHistory: (await this.ctx.storage.get(DIAGNOSTICS_HISTORY_KEY)) ?? [], + }); + } + + if (url.pathname === "/_container_event" && request.method === "POST") { + const payload = await request.json(); + await this.recordDiagnostic({ + event: "entrypoint", + at: new Date().toISOString(), + payload, + }); + return new Response("ok"); + } + + try { + return await this.containerFetch(request, this.defaultPort); + } catch (error) { + await this.recordDiagnostic({ + event: "container-fetch-error", + at: new Date().toISOString(), + message: error instanceof Error ? error.message : String(error), + }); + + return new Response( + `Failed to serve preview container: ${error instanceof Error ? error.message : String(error)}`, + { status: 500 } + ); + } + } + + override async onStart(): Promise { + await this.recordDiagnostic({ + event: "start", + at: new Date().toISOString(), + }); + } + + override async onStop(stopParams: { exitCode?: number; reason?: string }): Promise { + await this.recordDiagnostic({ + event: "stop", + at: new Date().toISOString(), + exitCode: stopParams.exitCode, + reason: stopParams.reason, + }); + } + + override async onError(error: unknown): Promise { + console.error("Rails container error:", error); + await this.recordDiagnostic({ + event: "error", + at: new Date().toISOString(), + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +export default { + async fetch( + request: Request, + env: Env, + _ctx: ExecutionContext + ): Promise { + const id = env.RAILS_CONTAINER.idFromName("preview"); + const container = env.RAILS_CONTAINER.get(id); + + return container.fetch(request); + }, +}; diff --git a/workers/preview/tsconfig.json b/workers/preview/tsconfig.json new file mode 100644 index 000000000..61f2674cb --- /dev/null +++ b/workers/preview/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/workers/preview/wrangler.toml b/workers/preview/wrangler.toml new file mode 100644 index 000000000..e180a62b7 --- /dev/null +++ b/workers/preview/wrangler.toml @@ -0,0 +1,40 @@ +# Cloudflare Containers configuration for PR preview deployments +# This file is used as a template - the GitHub workflow substitutes PR_NUMBER + +name = "sure-preview-${PR_NUMBER}" +main = "./src/index.ts" +compatibility_date = "2026-05-11" + +# Enable workers.dev subdomain for preview access +workers_dev = true + +# Enable container logs +[observability] +enabled = true + +# Container configuration - uses preview Dockerfile with embedded PostgreSQL +# Note: path is relative to this config file location +[[containers]] +class_name = "RailsContainer" +image = "../../Dockerfile.preview" +max_instances = 1 + +# Durable Object binding for the container +[[durable_objects.bindings]] +name = "RAILS_CONTAINER" +class_name = "RailsContainer" + +# Required migration for Durable Objects with SQLite +[[migrations]] +tag = "v1" +new_sqlite_classes = ["RailsContainer"] + +# Environment variables passed to the Rails container +[vars] +RAILS_ENV = "development" +RAILS_LOG_TO_STDOUT = "true" +RAILS_SERVE_STATIC_FILES = "true" + +# Note: SECRET_KEY_BASE and DATABASE_URL are auto-generated by the entrypoint +# for development previews. For custom configuration, set via: +# wrangler secret put SECRET_KEY_BASE From d74b1b2a11e3c4d96ce9c3593d6b5ca2c0724c2d Mon Sep 17 00:00:00 2001 From: "Sure Admin (bot)" Date: Sat, 16 May 2026 15:26:30 +0200 Subject: [PATCH 4/8] fix(preview): use worker list metadata for cleanup (#1799) * fix(preview): use worker list metadata for cleanup * fix(preview): handle cleanup edge cases * fix(preview): harden scheduled cleanup errors * feat(preview): add warmup screen and readiness gate * fix(preview): report success after image deploy * fix(preview): stop blocking healthy previews on stale status --- .github/workflows/preview-cleanup.yml | 66 ++++--- .github/workflows/preview-deploy.yml | 9 +- workers/preview/src/index.ts | 249 +++++++++++++++++++++++++- 3 files changed, 294 insertions(+), 30 deletions(-) diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml index 6c9cbf753..dba8ddccd 100644 --- a/.github/workflows/preview-cleanup.yml +++ b/.github/workflows/preview-cleanup.yml @@ -139,11 +139,28 @@ jobs: # Get list of all preview workers echo "Fetching list of preview workers..." - # Use Cloudflare API to list workers - WORKERS=$(curl -s -X GET \ + # Use Cloudflare API to list workers and read modified_on from the list response. + # The per-script endpoint returns raw script content, not JSON metadata. + WORKERS_RESPONSE=$(curl -fsS -X GET \ "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - -H "Content-Type: application/json" | jq -r '.result[] | select(.id | startswith("sure-preview-")) | .id') + -H "Content-Type: application/json") || { + echo "Failed to fetch preview worker list from Cloudflare" + exit 1 + } + + if ! echo "$WORKERS_RESPONSE" | jq -e '.success == true and (.result | type == "array")' >/dev/null 2>&1; then + echo "Cloudflare API returned an invalid worker list response" + echo "$WORKERS_RESPONSE" | jq -c '.errors // .' + exit 1 + fi + + WORKERS=$(echo "$WORKERS_RESPONSE" | jq -r ' + .result[] + | select(.id | startswith("sure-preview-")) + | [.id, (.modified_on // "")] + | @tsv + ') if [ -z "$WORKERS" ]; then echo "No preview workers found" @@ -151,46 +168,49 @@ jobs: fi echo "Found preview workers:" - echo "$WORKERS" + echo "$WORKERS" | cut -f1 # Check each worker's deployment time CUTOFF_TIME=$(date -d '24 hours ago' +%s) - for WORKER in $WORKERS; do + while IFS=$'\t' read -r WORKER MODIFIED_ON; do + [ -n "$WORKER" ] || continue echo "Checking $WORKER..." - # Get worker details to find last deployment time - WORKER_INFO=$(curl -s -X GET \ - "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/$WORKER" \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - -H "Content-Type: application/json") + if [ -z "$MODIFIED_ON" ]; then + echo "No modified_on timestamp for $WORKER; skipping" + continue + fi - MODIFIED_ON=$(echo "$WORKER_INFO" | jq -r '.result.modified_on // empty') - - if [ -n "$MODIFIED_ON" ]; then - MODIFIED_TS=$(date -d "$MODIFIED_ON" +%s 2>/dev/null || echo "0") - - if [ "$MODIFIED_TS" -lt "$CUTOFF_TIME" ]; then - echo "Worker $WORKER is older than 24 hours, deleting..." - wrangler delete --name "$WORKER" --force || echo "Failed to delete $WORKER" + if ! MODIFIED_TS=$(date -d "$MODIFIED_ON" +%s 2>/dev/null); then + echo "Invalid modified_on timestamp for $WORKER ($MODIFIED_ON); skipping" + continue + fi + if [ "$MODIFIED_TS" -lt "$CUTOFF_TIME" ]; then + echo "Worker $WORKER is older than 24 hours, deleting..." + if wrangler delete --name "$WORKER" --force; then # Extract PR number and cleanup GitHub deployment PR_NUM=$(echo "$WORKER" | sed 's/sure-preview-//') - if [ -n "$PR_NUM" ]; then + if [[ "$PR_NUM" =~ ^[1-9][0-9]*$ ]]; then echo "Cleaning up GitHub deployment for PR #$PR_NUM" gh api \ -X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \ - --jq '.[].id' | while read -r DEPLOY_ID; do + --jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do gh api \ -X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \ -f state=inactive \ -f description="Preview expired after 24 hours" || true - done + done || echo "No deployments to cleanup or error occurred" + else + echo "Could not extract a valid PR number from $WORKER; skipping deployment cleanup" fi else - echo "Worker $WORKER is still within 24-hour window, keeping..." + echo "Failed to delete $WORKER; skipping deployment status update" fi + else + echo "Worker $WORKER is still within 24-hour window, keeping..." fi - done + done <<< "$WORKERS" echo "Cleanup complete" diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 9d89ff413..4ecf03b5e 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -112,6 +112,13 @@ jobs: PREVIEW_URL="https://sure-preview-${{ github.event.pull_request.number }}.${{ secrets.CLOUDFLARE_WORKERS_SUBDOMAIN }}.workers.dev" echo "preview_url=${PREVIEW_URL}" >> "$GITHUB_OUTPUT" + - name: Warm preview container + env: + PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }} + run: | + echo "Triggering preview wake-up..." + curl -fsS "$PREVIEW_URL/" >/dev/null || true + - name: Update Deployment Status if: always() && steps.deployment.outputs.result uses: actions/github-script@v7 @@ -135,7 +142,7 @@ jobs: const previewUrl = '${{ steps.deploy.outputs.preview_url }}'; const commentBody = `## 🚀 Preview Deployment Ready - Your preview environment has been deployed to Cloudflare Containers. + Your preview environment has been deployed to Cloudflare Containers with the PR's Docker image. **Preview URL:** ${previewUrl} diff --git a/workers/preview/src/index.ts b/workers/preview/src/index.ts index becb8fbe7..872cf1a99 100644 --- a/workers/preview/src/index.ts +++ b/workers/preview/src/index.ts @@ -4,8 +4,61 @@ interface Env { RAILS_CONTAINER: DurableObjectNamespace; } +interface DiagnosticPayload { + stage?: string; + detail?: string; +} + +interface DiagnosticRecord { + event?: string; + at?: string; + payload?: DiagnosticPayload; + state?: { status?: string; lastChange?: number }; + message?: string; +} + +interface PreviewProgress { + phase: "cold" | "warming" | "loading-demo-data" | "ready" | "failed"; + stage: string | null; + message: string; + detail: string; +} + +interface PreviewStatusPayload { + state: unknown; + containerRunning: boolean; + diagnostics: DiagnosticRecord | null; + diagnosticsHistory: DiagnosticRecord[]; + previewReady: boolean; + previewFailed: boolean; + progress: PreviewProgress; +} + const DIAGNOSTICS_KEY = "preview-diagnostics"; const DIAGNOSTICS_HISTORY_KEY = "preview-diagnostics-history"; +const READY_STAGES = new Set(["demo-data-ready", "demo-data-skip"]); +const FAILED_STAGES = new Set(["demo-data-failed", "failed"]); +const WAITING_MESSAGES: Record = { + boot: "Waking preview…", + "redis-start": "Starting Redis…", + "redis-ready": "Redis is ready.", + "postgres-start": "Starting PostgreSQL…", + "postgres-ready": "PostgreSQL is ready.", + "postgres-already-running": "PostgreSQL is already running.", + "db-setup": "Setting up the preview database…", + "db-prepare": "Running database setup…", + "db-prepare-done": "Database setup finished.", + "demo-data-check": "Checking sample data…", + "demo-data-user-present": "Found the demo user. Verifying sample data…", + "demo-data-deferred": "Rails is up. Loading sample data…", + "demo-data-load": "Loading sample data…", + "demo-data-ready": "Sample data is ready.", + "demo-data-skip": "Sample data is already ready.", + "demo-data-failed": "Sample data failed to load.", + "rails-start": "Starting Rails…", + "rails-up-ready": "Rails is up. Finishing sample data…", + "rails-up-timeout": "Rails is taking longer than expected to start.", +}; export class RailsContainer extends Container { defaultPort = 3000; @@ -53,16 +106,191 @@ export class RailsContainer extends Container { await this.ctx.storage.put(DIAGNOSTICS_HISTORY_KEY, history); } + private async getDiagnostics(): Promise<{ + state: unknown; + containerRunning: boolean; + diagnostics: DiagnosticRecord | null; + diagnosticsHistory: DiagnosticRecord[]; + }> { + return { + state: await this.getState(), + containerRunning: this.runtimeContainer.running, + diagnostics: ((await this.ctx.storage.get(DIAGNOSTICS_KEY)) as DiagnosticRecord | undefined) ?? null, + diagnosticsHistory: + ((await this.ctx.storage.get(DIAGNOSTICS_HISTORY_KEY)) as DiagnosticRecord[] | undefined) ?? [], + }; + } + + private async probeRailsUp(): Promise { + try { + const response = await this.containerFetch(new Request("https://container.internal/up"), this.defaultPort); + return response.ok; + } catch { + return false; + } + } + + private async buildPreviewStatus(base: { + state: unknown; + containerRunning: boolean; + diagnostics: DiagnosticRecord | null; + diagnosticsHistory: DiagnosticRecord[]; + }, options?: { probe?: boolean }): Promise { + const allDiagnostics = [...base.diagnosticsHistory, ...(base.diagnostics ? [base.diagnostics] : [])]; + const entrypointDiagnostics = allDiagnostics.filter( + (item) => item.event === "entrypoint" && typeof item.payload?.stage === "string" + ); + const latestEntrypoint = entrypointDiagnostics.at(-1) ?? null; + const latestStage = latestEntrypoint?.payload?.stage ?? null; + const latestDetail = latestEntrypoint?.payload?.detail ?? base.diagnostics?.message ?? ""; + const sampleDataReady = entrypointDiagnostics.some((item) => READY_STAGES.has(item.payload?.stage ?? "")); + const liveProbeReady = options?.probe ? await this.probeRailsUp() : false; + const railsResponding = + liveProbeReady || + (typeof base.state === "object" && base.state !== null && "status" in base.state + ? (base.state as { status?: string }).status === "healthy" + : false) || + entrypointDiagnostics.some((item) => item.payload?.stage === "rails-up-ready"); + const previewReady = liveProbeReady || (sampleDataReady && railsResponding); + const previewFailed = + entrypointDiagnostics.some((item) => FAILED_STAGES.has(item.payload?.stage ?? "")) || + base.diagnostics?.event === "error"; + + let phase: PreviewProgress["phase"] = "cold"; + if (previewFailed) { + phase = "failed"; + } else if (previewReady) { + phase = "ready"; + } else if ( + latestStage === "demo-data-load" || + latestStage === "demo-data-deferred" || + latestStage === "rails-up-ready" || + latestStage === "demo-data-check" || + latestStage === "demo-data-user-present" + ) { + phase = "loading-demo-data"; + } else if (base.containerRunning || latestEntrypoint) { + phase = "warming"; + } + + const message = sampleDataReady && !previewReady + ? "Finishing preview startup…" + : (latestStage ? WAITING_MESSAGES[latestStage] : undefined) ?? + (previewFailed + ? "Preview startup hit an error." + : previewReady + ? "Preview is ready." + : base.containerRunning + ? "Warming preview…" + : "Starting preview…"); + + return { + ...base, + previewReady, + previewFailed, + progress: { + phase, + stage: latestStage, + message, + detail: latestDetail, + }, + }; + } + + private wantsHtml(request: Request): boolean { + if (request.method !== "GET") return false; + const accept = request.headers.get("accept") ?? ""; + const secFetchDest = request.headers.get("sec-fetch-dest") ?? ""; + return accept.includes("text/html") || secFetchDest === "document"; + } + + private renderWaitPage(request: Request, status: PreviewStatusPayload, errorMessage?: string): Response { + const targetPath = new URL(request.url).pathname + new URL(request.url).search; + const escapedTargetPath = JSON.stringify(targetPath); + const escapedMessage = JSON.stringify(status.progress.message); + const escapedDetail = JSON.stringify( + status.progress.detail || errorMessage || "This preview is waking up and loading sample data." + ); + + const html = ` + + + + + Waking preview… + + + +
+
+ +

+

+

Please wait — this preview is cold-starting and will redirect automatically when the sample data is ready.

+

+
+
+ + +`; + + return new Response(html, { + status: status.previewFailed ? 503 : 202, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store, max-age=0", + "retry-after": "3", + }, + }); + } + override async fetch(request: Request): Promise { const url = new URL(request.url); if (url.pathname === "/_container_status") { - return Response.json({ - state: await this.getState(), - containerRunning: this.runtimeContainer.running, - diagnostics: (await this.ctx.storage.get(DIAGNOSTICS_KEY)) ?? null, - diagnosticsHistory: (await this.ctx.storage.get(DIAGNOSTICS_HISTORY_KEY)) ?? [], - }); + return Response.json(await this.buildPreviewStatus(await this.getDiagnostics(), { probe: true })); } if (url.pathname === "/_container_event" && request.method === "POST") { @@ -84,6 +312,15 @@ export class RailsContainer extends Container { message: error instanceof Error ? error.message : String(error), }); + const status = await this.buildPreviewStatus(await this.getDiagnostics()); + if (this.wantsHtml(request) && !status.previewReady) { + return this.renderWaitPage( + request, + status, + error instanceof Error ? error.message : String(error) + ); + } + return new Response( `Failed to serve preview container: ${error instanceof Error ? error.message : String(error)}`, { status: 500 } From 0c126b16745cf7e769bf7e01c1f5a73135dea701 Mon Sep 17 00:00:00 2001 From: Brendon Scheiber <62311356+brandonvers@users.noreply.github.com> Date: Sun, 17 May 2026 09:52:49 +0200 Subject: [PATCH 5/8] feat(i18n): extract hardcoded English strings to locale files (#1806) * Extract hardcoded strings to i18n Replace numerous hardcoded English strings with I18n lookups (t / I18n.t) across controllers, views, helpers, and components, and convert model validation error messages to symbol keys. Added multiple locale files under config/locales for models and views. This centralizes user-facing notices/alerts, UI text, import/validation messages, and prepares the app for localization and easier translation maintenance. * Update en.yml * Update preview-cleanup.yml * Revert "Update preview-cleanup.yml" This reverts commit 1ba6d3c34ca3c7141ab02cae9a9bdbc23dc6f79a. * test: align i18n assertions with translated messages * Standardize balance error key and tweak locales Replace SophtronAccount's :requires_balance error key with :no_balance and update related locale strings for sophtron, plaid, and simplefin accounts to use the new key and clearer copy. Also switch the QIF upload redirect notice to use a relative translation key (t('.qif_uploaded')), remove an unused SSO providers help line, and fix a trailing-newline/whitespace issue in the subscriptions locale. These changes standardize validation keys and improve translation consistency and messaging. --------- Co-authored-by: KiloClaw --- .../UI/account/activity_date.html.erb | 4 +- app/components/UI/account/chart.html.erb | 2 +- app/controllers/categories_controller.rb | 2 +- app/controllers/chats_controller.rb | 4 +- app/controllers/concerns/self_hostable.rb | 2 +- app/controllers/holdings_controller.rb | 2 +- app/controllers/import/cleans_controller.rb | 2 +- app/controllers/import/confirms_controller.rb | 2 +- .../qif_category_selections_controller.rb | 2 +- app/controllers/import/uploads_controller.rb | 2 +- app/controllers/imports_controller.rb | 12 +- app/controllers/invite_codes_controller.rb | 4 +- app/controllers/oidc_accounts_controller.rb | 12 +- .../pending_duplicate_merges_controller.rb | 10 +- app/controllers/plaid_items_controller.rb | 8 +- app/controllers/rules_controller.rb | 8 +- .../settings/api_keys_controller.rb | 10 +- .../settings/profiles_controller.rb | 4 +- .../settings/providers_controller.rb | 4 +- app/controllers/snaptrade_items_controller.rb | 2 +- app/controllers/subscriptions_controller.rb | 10 +- app/controllers/tags_controller.rb | 2 +- app/controllers/transactions_controller.rb | 4 +- .../transfer_matches_controller.rb | 2 +- app/controllers/users_controller.rb | 2 +- app/controllers/valuations_controller.rb | 6 +- app/helpers/application_helper.rb | 4 +- app/helpers/categories_helper.rb | 6 +- app/helpers/custom_confirm.rb | 6 +- app/helpers/imports_helper.rb | 40 ++--- app/models/api_key.rb | 2 +- app/models/category_import.rb | 4 +- app/models/import.rb | 2 +- app/models/indexa_capital_item.rb | 2 +- app/models/plaid_account.rb | 2 +- app/models/recurring_transaction.rb | 2 +- app/models/rule.rb | 6 +- app/models/rule_import.rb | 6 +- app/models/simplefin_account.rb | 2 +- app/models/sophtron_account.rb | 2 +- app/models/sso_provider.rb | 8 +- app/models/transfer.rb | 10 +- app/views/accounts/_account.html.erb | 2 +- app/views/accounts/new/_container.html.erb | 6 +- app/views/accounts/show/_activity.html.erb | 6 +- app/views/accounts/show/_header.html.erb | 2 +- app/views/accounts/show/_menu.html.erb | 6 +- app/views/admin/sso_providers/_form.html.erb | 64 +++---- app/views/admin/sso_providers/index.html.erb | 35 ++-- .../_assistant_message.html.erb | 2 +- .../assistant_messages/_tool_calls.html.erb | 6 +- .../_confirm_button.html.erb | 2 +- .../budget_categories/_no_categories.html.erb | 8 +- app/views/budget_categories/index.html.erb | 4 +- app/views/budget_categories/show.html.erb | 26 +-- app/views/budgets/_actuals_summary.html.erb | 4 +- app/views/budgets/_budget_donut.html.erb | 10 +- app/views/budgets/_budget_header.html.erb | 2 +- app/views/budgets/_budgeted_summary.html.erb | 16 +- .../budgets/_over_allocation_warning.html.erb | 4 +- app/views/budgets/edit.html.erb | 14 +- app/views/categories/_form.html.erb | 10 +- app/views/categories/index.html.erb | 2 +- app/views/category/dropdowns/show.html.erb | 4 +- app/views/chats/_ai_consent.html.erb | 11 +- app/views/chats/_ai_greeting.html.erb | 12 +- app/views/chats/_chat.html.erb | 4 +- app/views/chats/_chat_nav.html.erb | 8 +- app/views/chats/_error.html.erb | 2 +- app/views/chats/index.html.erb | 6 +- app/views/credit_cards/_overview.html.erb | 2 +- .../doorkeeper/authorizations/error.html.erb | 2 +- .../doorkeeper/authorizations/show.html.erb | 4 +- .../_enable_banking_item.html.erb | 26 +-- app/views/enable_banking_items/new.html.erb | 40 ++--- .../select_existing_account.html.erb | 14 +- .../setup_accounts.html.erb | 22 +-- app/views/family_exports/new.html.erb | 18 +- .../_family_merchant.html.erb | 4 +- app/views/holdings/_cost_basis_cell.html.erb | 2 +- .../select_existing_account.html.erb | 14 +- .../_super_admin_bar.html.erb | 18 +- app/views/import/cleans/show.html.erb | 8 +- .../configurations/_account_import.html.erb | 14 +- .../configurations/_trade_import.html.erb | 24 +-- .../_transaction_import.html.erb | 46 ++--- app/views/import/confirms/_mappings.html.erb | 4 +- app/views/import/uploads/show.html.erb | 18 +- app/views/imports/_failure.html.erb | 6 +- app/views/imports/_importing.html.erb | 8 +- app/views/imports/_revert_failure.html.erb | 6 +- app/views/imports/_success.html.erb | 6 +- .../layouts/shared/_confirm_dialog.html.erb | 6 +- app/views/loans/tabs/_overview.html.erb | 2 +- app/views/lunchflow_items/_api_error.html.erb | 16 +- .../lunchflow_items/_setup_required.html.erb | 18 +- app/views/mercury_items/_api_error.html.erb | 18 +- .../mercury_items/_setup_required.html.erb | 18 +- app/views/messages/_chat_form.html.erb | 4 +- .../pages/dashboard/_balance_sheet.html.erb | 6 +- app/views/pages/feedback.html.erb | 12 +- app/views/pages/intro.html.erb | 8 +- .../pages/redis_configuration_error.html.erb | 18 +- .../properties/_overview_fields.html.erb | 18 +- app/views/properties/address.html.erb | 24 +-- app/views/properties/balances.html.erb | 8 +- app/views/properties/new.html.erb | 4 +- app/views/properties/tabs/_overview.html.erb | 2 +- app/views/reports/_investment_flows.html.erb | 14 +- .../rule/conditions/_condition_group.html.erb | 10 +- app/views/rules/_form.html.erb | 16 +- app/views/rules/_rule.html.erb | 18 +- app/views/rules/confirm.html.erb | 25 ++- app/views/rules/index.html.erb | 22 +-- app/views/sessions/mobile_sso_start.html.erb | 2 +- app/views/settings/_settings_nav.html.erb | 6 +- app/views/settings/api_keys/created.html.erb | 39 ++--- .../api_keys/created.turbo_stream.erb | 41 +++-- app/views/settings/api_keys/new.html.erb | 27 ++- app/views/settings/api_keys/show.html.erb | 46 ++--- .../hostings/_brand_fetch_settings.html.erb | 10 +- app/views/settings/llm_usages/show.html.erb | 51 +++--- app/views/settings/payments/show.html.erb | 16 +- .../providers/_enable_banking_panel.html.erb | 40 ++--- .../providers/_lunchflow_panel.html.erb | 10 +- .../providers/_simplefin_panel.html.erb | 6 +- app/views/simplefin_items/edit.html.erb | 18 +- .../simplefin_items/setup_accounts.html.erb | 32 ++-- app/views/subscriptions/upgrade.html.erb | 6 +- app/views/tag/deletions/new.html.erb | 2 +- app/views/tags/index.html.erb | 2 +- app/views/transactions/index.html.erb | 18 +- .../transactions/searches/_form.html.erb | 4 +- .../searches/filters/_date_filter.html.erb | 4 +- app/views/transactions/show.html.erb | 20 +-- .../_matching_fields.html.erb | 17 +- app/views/transfer_matches/new.html.erb | 10 +- app/views/transfers/show.html.erb | 14 +- app/views/users/_user_menu.html.erb | 12 +- .../_confirmation_contents.html.erb | 30 ++-- app/views/valuations/show.html.erb | 4 +- app/views/vehicles/tabs/_overview.html.erb | 2 +- config/locales/doorkeeper.en.yml | 3 + config/locales/models/api_key/en.yml | 7 + config/locales/models/category_import/en.yml | 8 + config/locales/models/import/en.yml | 1 + .../locales/models/indexa_capital_item/en.yml | 7 + config/locales/models/plaid_account/en.yml | 7 + .../models/recurring_transaction/en.yml | 7 + config/locales/models/rule/en.yml | 9 + config/locales/models/rule_import/en.yml | 9 + .../locales/models/simplefin_account/en.yml | 7 + config/locales/models/sophtron_account/en.yml | 7 + config/locales/models/sso_provider/en.yml | 12 ++ config/locales/models/transfer/en.yml | 4 + config/locales/views/accounts/en.yml | 14 ++ .../locales/views/admin/sso_providers/en.yml | 49 ++++-- config/locales/views/budgets/en.yml | 52 ++++++ config/locales/views/categories/en.yml | 17 ++ config/locales/views/chats/en.yml | 38 ++++ config/locales/views/components/en.yml | 9 + config/locales/views/credit_cards/en.yml | 1 + .../locales/views/enable_banking_items/en.yml | 59 ++++++- config/locales/views/family_exports/en.yml | 12 ++ config/locales/views/holdings/en.yml | 2 + config/locales/views/ibkr_items/en.yml | 8 + .../views/impersonation_sessions/en.yml | 10 ++ config/locales/views/imports/en.yml | 162 +++++++++++++++++- config/locales/views/invite_codes/en.yml | 4 + config/locales/views/layout/en.yml | 4 + config/locales/views/loans/en.yml | 12 ++ config/locales/views/lunchflow_items/en.yml | 23 +++ config/locales/views/merchants/en.yml | 3 + config/locales/views/mercury_items/en.yml | 30 ++++ config/locales/views/messages/en.yml | 6 + config/locales/views/oidc_accounts/en.yml | 8 + config/locales/views/pages/en.yml | 26 +++ .../views/pending_duplicate_merges/en.yml | 7 + config/locales/views/plaid_items/en.yml | 5 + config/locales/views/properties/en.yml | 43 ++++- config/locales/views/reports/en.yml | 5 +- config/locales/views/rules/en.yml | 62 +++++++ config/locales/views/sessions/en.yml | 2 + config/locales/views/settings/api_keys/en.yml | 53 +++++- config/locales/views/settings/en.yml | 76 ++++++++ config/locales/views/settings/hostings/en.yml | 9 + config/locales/views/shared/en.yml | 15 ++ config/locales/views/simplefin_items/en.yml | 27 +++ config/locales/views/snaptrade_items/en.yml | 4 + config/locales/views/sophtron_items/en.yml | 22 +++ config/locales/views/subscriptions/en.yml | 12 +- config/locales/views/tag/deletions/en.yml | 1 + config/locales/views/tags/en.yml | 3 + config/locales/views/transactions/en.yml | 36 ++++ config/locales/views/transfer_matches/en.yml | 17 ++ config/locales/views/transfers/en.yml | 6 + config/locales/views/users/en.yml | 7 + config/locales/views/valuations/en.yml | 27 +++ config/locales/views/vehicles/en.yml | 10 ++ .../settings/profiles_controller_test.rb | 4 +- test/models/simplefin_account_test.rb | 2 +- 201 files changed, 1845 insertions(+), 835 deletions(-) create mode 100644 config/locales/models/api_key/en.yml create mode 100644 config/locales/models/category_import/en.yml create mode 100644 config/locales/models/indexa_capital_item/en.yml create mode 100644 config/locales/models/plaid_account/en.yml create mode 100644 config/locales/models/recurring_transaction/en.yml create mode 100644 config/locales/models/rule/en.yml create mode 100644 config/locales/models/rule_import/en.yml create mode 100644 config/locales/models/simplefin_account/en.yml create mode 100644 config/locales/models/sophtron_account/en.yml create mode 100644 config/locales/models/sso_provider/en.yml create mode 100644 config/locales/views/messages/en.yml diff --git a/app/components/UI/account/activity_date.html.erb b/app/components/UI/account/activity_date.html.erb index f563801ab..6fd158d53 100644 --- a/app/components/UI/account/activity_date.html.erb +++ b/app/components/UI/account/activity_date.html.erb @@ -21,7 +21,7 @@
<%= end_balance_money.format %> - <%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %> + <%= render DS::Tooltip.new(text: t(".balance_tooltip"), placement: "left", size: "sm") %>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
@@ -32,7 +32,7 @@ <% if balance %> <%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %> <% else %> -

No balance data available for this date

+

<%= t(".no_balance_data") %>

<% end %> diff --git a/app/components/UI/account/chart.html.erb b/app/components/UI/account/chart.html.erb index efcdca7d6..54933dcfe 100644 --- a/app/components/UI/account/chart.html.erb +++ b/app/components/UI/account/chart.html.erb @@ -50,7 +50,7 @@ data-time-series-chart-data-value="<%= series.to_json %>"> <% else %>
-

No data available

+

<%= t(".no_data_available") %>

<% end %> diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 6d5e6b9fc..a38e5166c 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -58,7 +58,7 @@ class CategoriesController < ApplicationController def destroy_all Current.family.categories.destroy_all - redirect_back_or_to categories_path, notice: "All categories deleted" + redirect_back_or_to categories_path, notice: t(".success") end def bootstrap diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb index eb3e2c5c7..aefb7daf4 100644 --- a/app/controllers/chats_controller.rb +++ b/app/controllers/chats_controller.rb @@ -29,7 +29,7 @@ class ChatsController < ApplicationController @chat.update!(chat_params) respond_to do |format| - format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" } + format.html { redirect_back_or_to chat_path(@chat), notice: t(".success") } format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) } end end @@ -38,7 +38,7 @@ class ChatsController < ApplicationController @chat.destroy clear_last_viewed_chat - redirect_to chats_path, notice: "Chat was successfully deleted" + redirect_to chats_path, notice: t(".notice") end def retry diff --git a/app/controllers/concerns/self_hostable.rb b/app/controllers/concerns/self_hostable.rb index 3631571ae..83498269b 100644 --- a/app/controllers/concerns/self_hostable.rb +++ b/app/controllers/concerns/self_hostable.rb @@ -23,7 +23,7 @@ module SelfHostable if controller_name == "pages" && action_name == "redis_configuration_error" # If Redis is now working, redirect to home if redis_connected? - redirect_to root_path, notice: "Redis is now configured properly! You can now setup your Sure application." + redirect_to root_path, notice: t("concerns.self_hostable.redis_configured") end return diff --git a/app/controllers/holdings_controller.rb b/app/controllers/holdings_controller.rb index 14c7f55b8..45a89f938 100644 --- a/app/controllers/holdings_controller.rb +++ b/app/controllers/holdings_controller.rb @@ -42,7 +42,7 @@ class HoldingsController < ApplicationController @holding.destroy_holding_and_entries! flash[:notice] = t(".success") else - flash[:alert] = "You cannot delete this holding" + flash[:alert] = t(".cannot_delete") end respond_to do |format| diff --git a/app/controllers/import/cleans_controller.rb b/app/controllers/import/cleans_controller.rb index 7d91f2134..4b0b46c16 100644 --- a/app/controllers/import/cleans_controller.rb +++ b/app/controllers/import/cleans_controller.rb @@ -6,7 +6,7 @@ class Import::CleansController < ApplicationController def show unless @import.configured? redirect_path = @import.is_a?(PdfImport) ? import_path(@import) : import_configuration_path(@import) - return redirect_to redirect_path, alert: "Please configure your import before proceeding." + return redirect_to redirect_path, alert: t(".not_configured") end rows = @import.rows_ordered diff --git a/app/controllers/import/confirms_controller.rb b/app/controllers/import/confirms_controller.rb index 1a687d4bd..029a58469 100644 --- a/app/controllers/import/confirms_controller.rb +++ b/app/controllers/import/confirms_controller.rb @@ -8,7 +8,7 @@ class Import::ConfirmsController < ApplicationController return redirect_to import_path(@import) end - redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned? + redirect_to import_clean_path(@import), alert: t(".invalid_data") unless @import.cleaned? end private diff --git a/app/controllers/import/qif_category_selections_controller.rb b/app/controllers/import/qif_category_selections_controller.rb index 2ed7b195a..cf7538b9b 100644 --- a/app/controllers/import/qif_category_selections_controller.rb +++ b/app/controllers/import/qif_category_selections_controller.rb @@ -54,7 +54,7 @@ class Import::QifCategorySelectionsController < ApplicationController @import.sync_mappings unless format_changed end - redirect_to import_clean_path(@import), notice: "Categories and tags saved." + redirect_to import_clean_path(@import), notice: t(".success") end private diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index 0212bc850..bd08c3e2a 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -85,7 +85,7 @@ class Import::UploadsController < ApplicationController @import.sync_mappings end - redirect_to import_qif_category_selection_path(@import), notice: "QIF file uploaded successfully." + redirect_to import_qif_category_selection_path(@import), notice: t(".qif_uploaded") end def csv_str diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 4a3cc0492..108cbd393 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -22,9 +22,9 @@ class ImportsController < ApplicationController def publish @import.publish_later - redirect_to import_path(@import), notice: "Your import has started in the background." + redirect_to import_path(@import), notice: t(".started") rescue Import::MaxRowCountExceededError - redirect_back_or_to import_path(@import), alert: "Your import exceeds the maximum row count of #{@import.max_row_count}." + redirect_back_or_to import_path(@import), alert: t(".max_rows_exceeded", max: @import.max_row_count) end def index @@ -112,22 +112,22 @@ class ImportsController < ApplicationController def revert @import.revert_later - redirect_to imports_path, notice: "Import is reverting in the background." + redirect_to imports_path, notice: t(".started") end def apply_template if @import.suggested_template @import.apply_template!(@import.suggested_template) - redirect_to import_configuration_path(@import), notice: "Template applied." + redirect_to import_configuration_path(@import), notice: t(".template_applied") else - redirect_to import_configuration_path(@import), alert: "No template found, please manually configure your import." + redirect_to import_configuration_path(@import), alert: t(".no_template_found") end end def destroy @import.destroy - redirect_to imports_path, notice: "Your import has been deleted." + redirect_to imports_path, notice: t(".deleted") end private diff --git a/app/controllers/invite_codes_controller.rb b/app/controllers/invite_codes_controller.rb index f9bcf6760..436e9a7d1 100644 --- a/app/controllers/invite_codes_controller.rb +++ b/app/controllers/invite_codes_controller.rb @@ -8,13 +8,13 @@ class InviteCodesController < ApplicationController def create InviteCode.generate! - redirect_back_or_to invite_codes_path, notice: "Code generated" + redirect_back_or_to invite_codes_path, notice: t(".success") end def destroy code = InviteCode.find(params[:id]) code.destroy - redirect_back_or_to invite_codes_path, notice: "Code deleted" + redirect_back_or_to invite_codes_path, notice: t(".success") end private diff --git a/app/controllers/oidc_accounts_controller.rb b/app/controllers/oidc_accounts_controller.rb index 25548995d..a0e31e7a4 100644 --- a/app/controllers/oidc_accounts_controller.rb +++ b/app/controllers/oidc_accounts_controller.rb @@ -7,7 +7,7 @@ class OidcAccountsController < ApplicationController @pending_auth = session[:pending_oidc_auth] if @pending_auth.nil? - redirect_to new_session_path, alert: "No pending OIDC authentication found" + redirect_to new_session_path, alert: t(".no_pending_oidc") return end @@ -26,7 +26,7 @@ class OidcAccountsController < ApplicationController @pending_auth = session[:pending_oidc_auth] if @pending_auth.nil? - redirect_to new_session_path, alert: "No pending OIDC authentication found" + redirect_to new_session_path, alert: t(".no_pending_oidc") return end @@ -75,7 +75,7 @@ class OidcAccountsController < ApplicationController @pending_auth = session[:pending_oidc_auth] if @pending_auth.nil? - redirect_to new_session_path, alert: "No pending OIDC authentication found" + redirect_to new_session_path, alert: t(".no_pending_oidc") return end @@ -91,7 +91,7 @@ class OidcAccountsController < ApplicationController @pending_auth = session[:pending_oidc_auth] if @pending_auth.nil? - redirect_to new_session_path, alert: "No pending OIDC authentication found" + redirect_to new_session_path, alert: t(".no_pending_oidc") return end @@ -104,7 +104,7 @@ class OidcAccountsController < ApplicationController # domain is not allowed, block JIT account creation—unless there's a # pending invitation for this user. unless invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email)) - redirect_to new_session_path, alert: "SSO account creation is disabled. Please contact an administrator." + redirect_to new_session_path, alert: t(".account_creation_disabled") return end @@ -164,7 +164,7 @@ class OidcAccountsController < ApplicationController elsif accept_pending_invitation_for(@user) t("invitations.accept_choice.joined_household") else - "Welcome! Your account has been created." + t(".account_created") end redirect_to root_path, notice: notice else diff --git a/app/controllers/pending_duplicate_merges_controller.rb b/app/controllers/pending_duplicate_merges_controller.rb index 9b690daa5..ec2914097 100644 --- a/app/controllers/pending_duplicate_merges_controller.rb +++ b/app/controllers/pending_duplicate_merges_controller.rb @@ -21,7 +21,7 @@ class PendingDuplicateMergesController < ApplicationController # Manually merge the pending transaction with the selected posted transaction unless merge_params[:posted_entry_id].present? - redirect_back_or_to transactions_path, alert: "Please select a posted transaction to merge with" + redirect_back_or_to transactions_path, alert: t(".no_posted_selected") return end @@ -29,7 +29,7 @@ class PendingDuplicateMergesController < ApplicationController posted_entry = find_eligible_posted_entry(merge_params[:posted_entry_id]) unless posted_entry - redirect_back_or_to transactions_path, alert: "Invalid transaction selected for merge" + redirect_back_or_to transactions_path, alert: t(".invalid_transaction") return end @@ -48,9 +48,9 @@ class PendingDuplicateMergesController < ApplicationController # Immediately merge if @transaction.merge_with_duplicate! - redirect_back_or_to transactions_path, notice: "Pending transaction merged with posted transaction" + redirect_back_or_to transactions_path, notice: t(".merge_success") else - redirect_back_or_to transactions_path, alert: "Could not merge transactions" + redirect_back_or_to transactions_path, alert: t(".merge_failed") end rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed, ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout => e @@ -64,7 +64,7 @@ class PendingDuplicateMergesController < ApplicationController @transaction = entry.entryable unless @transaction.is_a?(Transaction) && @transaction.pending? - redirect_to transactions_path, alert: "This feature is only available for pending transactions" + redirect_to transactions_path, alert: t("pending_duplicate_merges.set_transaction.pending_only") end end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index bfe8c5bd5..08de7e0b4 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -62,7 +62,7 @@ class PlaidItemsController < ApplicationController .select { |pa| pa.account_provider.nil? && pa.account.nil? } # Not linked via new or legacy system if @available_plaid_accounts.empty? - redirect_to account_path(@account), alert: "No available Plaid accounts to link. Please connect a new Plaid account first." + redirect_to account_path(@account), alert: t(".no_available_accounts") end end @@ -72,13 +72,13 @@ class PlaidItemsController < ApplicationController # Verify the Plaid account belongs to this family's Plaid items unless Current.family.plaid_items.include?(plaid_account.plaid_item) - redirect_to account_path(@account), alert: "Invalid Plaid account selected" + redirect_to account_path(@account), alert: t(".invalid_account") return end # Verify the Plaid account is not already linked if plaid_account.account_provider.present? || plaid_account.account.present? - redirect_to account_path(@account), alert: "This Plaid account is already linked" + redirect_to account_path(@account), alert: t(".already_linked") return end @@ -88,7 +88,7 @@ class PlaidItemsController < ApplicationController provider: plaid_account ) - redirect_to accounts_path, notice: "Account successfully linked to Plaid" + redirect_to accounts_path, notice: t(".success") end private diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 5e7496cb8..ae9e1ee1f 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -86,8 +86,8 @@ class RulesController < ApplicationController def update if @rule.update(rule_params) respond_to do |format| - format.html { redirect_back_or_to rules_path, notice: "Rule updated" } - format.turbo_stream { stream_redirect_back_or_to rules_path, notice: "Rule updated" } + format.html { redirect_back_or_to rules_path, notice: t(".success") } + format.turbo_stream { stream_redirect_back_or_to rules_path, notice: t(".success") } end else render :edit, status: :unprocessable_entity @@ -96,12 +96,12 @@ class RulesController < ApplicationController def destroy @rule.destroy - redirect_to rules_path, notice: "Rule deleted" + redirect_to rules_path, notice: t(".success") end def destroy_all Current.family.rules.destroy_all - redirect_to rules_path, notice: "All rules deleted" + redirect_to rules_path, notice: t(".success") end def confirm_all diff --git a/app/controllers/settings/api_keys_controller.rb b/app/controllers/settings/api_keys_controller.rb index c509df41a..d132e9a5f 100644 --- a/app/controllers/settings/api_keys_controller.rb +++ b/app/controllers/settings/api_keys_controller.rb @@ -31,7 +31,7 @@ class Settings::ApiKeysController < ApplicationController existing_keys.each { |key| key.update_column(:revoked_at, Time.current) } if @api_key.save - flash[:notice] = "Your API key has been created successfully" + flash[:notice] = t(".success") redirect_to settings_api_key_path else # Restore existing keys if new key creation failed @@ -42,13 +42,13 @@ class Settings::ApiKeysController < ApplicationController def destroy if @api_key.nil? - flash[:alert] = "API key not found" + flash[:alert] = t(".not_found") elsif @api_key.demo_monitoring_key? - flash[:alert] = "This API key cannot be revoked" + flash[:alert] = t(".cannot_revoke") elsif @api_key.revoke! - flash[:notice] = "API key has been revoked successfully" + flash[:notice] = t(".revoked_successfully") else - flash[:alert] = "Failed to revoke API key" + flash[:alert] = t(".revoke_failed") end redirect_to settings_api_key_path end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 6e8dbbf0e..7015e55f4 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -29,9 +29,9 @@ class Settings::ProfilesController < ApplicationController if @user.destroy # Also destroy the invitation associated with this user for this family Current.family.invitations.find_by(email: @user.email)&.destroy - flash[:notice] = "Member removed successfully." + flash[:notice] = t(".member_removed") else - flash[:alert] = "Failed to remove member." + flash[:alert] = t(".member_removal_failed") end redirect_to settings_profile_path diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index bdd9b485e..014be3d74 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -66,9 +66,9 @@ class Settings::ProvidersController < ApplicationController # Reload provider configurations if needed reload_provider_configs(updated_fields) - redirect_to settings_providers_path, notice: "Provider settings updated successfully" + redirect_to settings_providers_path, notice: t(".updated_successfully") else - redirect_to settings_providers_path, notice: "No changes were made" + redirect_to settings_providers_path, notice: t(".no_changes") end rescue => error Rails.logger.error("Failed to update provider settings: #{error.class} - #{error.message}") diff --git a/app/controllers/snaptrade_items_controller.rb b/app/controllers/snaptrade_items_controller.rb index 6fab6b0ec..6062c4dfb 100644 --- a/app/controllers/snaptrade_items_controller.rb +++ b/app/controllers/snaptrade_items_controller.rb @@ -380,7 +380,7 @@ class SnaptradeItemsController < ApplicationController end def link_accounts - redirect_to settings_providers_path, alert: "Use the account setup flow instead" + redirect_to settings_providers_path, alert: t(".use_setup_flow") end def select_existing_account diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index da02534bd..d6d5ba7d7 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -8,7 +8,7 @@ class SubscriptionsController < ApplicationController # Upgrade page for unsubscribed users def upgrade if Current.family.subscription&.active? - redirect_to root_path, notice: "You are already contributing. Thank you!" + redirect_to root_path, notice: t(".already_contributing") else @plan = params[:plan] || "annual" render layout: "onboardings" @@ -33,9 +33,9 @@ class SubscriptionsController < ApplicationController def create if Current.family.can_start_trial? Current.family.start_trial_subscription! - redirect_to root_path, notice: "Welcome to Sure!" + redirect_to root_path, notice: t(".welcome") else - redirect_to root_path, alert: "You have already started or completed a trial. Please upgrade to continue." + redirect_to root_path, alert: t(".trial_already_used") end end @@ -54,9 +54,9 @@ class SubscriptionsController < ApplicationController if checkout_result.success? Current.family.start_subscription!(checkout_result.subscription_id) - redirect_to root_path, notice: "Welcome to Sure! Your contribution is appreciated." + redirect_to root_path, notice: t(".welcome_with_contribution") else - redirect_to root_path, alert: "Something went wrong processing your contribution. Please try again." + redirect_to root_path, alert: t(".contribution_failed") end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 1c2862907..219971529 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -36,7 +36,7 @@ class TagsController < ApplicationController def destroy_all Current.family.tags.destroy_all - redirect_back_or_to tags_path, notice: "All tags deleted" + redirect_back_or_to tags_path, notice: t(".all_deleted") end private diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 8ba56d8e1..a1932c555 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -107,7 +107,7 @@ class TransactionsController < ApplicationController @entry.mark_user_modified! @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any? - flash[:notice] = "Transaction created" + flash[:notice] = t(".created") respond_to do |format| format.html { redirect_back_or_to account_path(@entry.account) } @@ -141,7 +141,7 @@ class TransactionsController < ApplicationController @entry.reload respond_to do |format| - format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" } + format.html { redirect_back_or_to account_path(@entry.account), notice: t(".updated") } format.turbo_stream do in_split_group = helpers.in_split_group?(@entry, params[:grouped]) render turbo_stream: [ diff --git a/app/controllers/transfer_matches_controller.rb b/app/controllers/transfer_matches_controller.rb index 341688d5d..4e67653ba 100644 --- a/app/controllers/transfer_matches_controller.rb +++ b/app/controllers/transfer_matches_controller.rb @@ -32,7 +32,7 @@ class TransferMatchesController < ApplicationController @transfer.sync_account_later - redirect_back_or_to transactions_path, notice: "Transfer created" + redirect_back_or_to transactions_path, notice: t(".success") end private diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 1ee9ee95b..d265734c6 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -6,7 +6,7 @@ class UsersController < ApplicationController if @user.resend_confirmation_email redirect_to settings_profile_path, notice: t(".success") else - redirect_to settings_profile_path, alert: t("no_pending_change") + redirect_to settings_profile_path, alert: t(".no_pending_change") end end diff --git a/app/controllers/valuations_controller.rb b/app/controllers/valuations_controller.rb index 5a2966167..9dbfdd597 100644 --- a/app/controllers/valuations_controller.rb +++ b/app/controllers/valuations_controller.rb @@ -57,8 +57,8 @@ class ValuationsController < ApplicationController if result.success? respond_to do |format| - format.html { redirect_back_or_to account_path(account), notice: "Account updated" } - format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: "Account updated") } + format.html { redirect_back_or_to account_path(account), notice: t(".account_updated") } + format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: t(".account_updated")) } end else @error_message = result.error_message @@ -84,7 +84,7 @@ class ValuationsController < ApplicationController @entry.reload respond_to do |format| - format.html { redirect_back_or_to account_path(@entry.account), notice: "Entry updated" } + format.html { redirect_back_or_to account_path(@entry.account), notice: t(".entry_updated") } format.turbo_stream do render turbo_stream: [ turbo_stream.replace( diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f00d876ea..d15266e03 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -74,7 +74,7 @@ module ApplicationHelper def family_moniker - Current.family&.moniker_label || "Family" + Current.family&.moniker_label || I18n.t("shared.family_moniker.singular") end def family_moniker_downcase @@ -82,7 +82,7 @@ module ApplicationHelper end def family_moniker_plural - Current.family&.moniker_label_plural || "Families" + Current.family&.moniker_label_plural || I18n.t("shared.family_moniker.plural") end def family_moniker_plural_downcase diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb index 2d586dee7..458dae432 100644 --- a/app/helpers/categories_helper.rb +++ b/app/helpers/categories_helper.rb @@ -1,21 +1,21 @@ module CategoriesHelper def transfer_category Category.new \ - name: "Transfer", + name: I18n.t("categories.virtual.transfer"), color: Category::TRANSFER_COLOR, lucide_icon: "arrow-right-left" end def payment_category Category.new \ - name: "Payment", + name: I18n.t("categories.virtual.payment"), color: Category::PAYMENT_COLOR, lucide_icon: "arrow-right" end def trade_category Category.new \ - name: "Trade", + name: I18n.t("categories.virtual.trade"), color: Category::TRADE_COLOR end diff --git a/app/helpers/custom_confirm.rb b/app/helpers/custom_confirm.rb index cdf245853..f2d59a009 100644 --- a/app/helpers/custom_confirm.rb +++ b/app/helpers/custom_confirm.rb @@ -38,14 +38,14 @@ class CustomConfirm end def default_title - "Are you sure?" + I18n.t("shared.custom_confirm.default_title") end def default_body - "This is not reversible." + I18n.t("shared.custom_confirm.default_body") end def default_btn_text - "Confirm" + I18n.t("shared.custom_confirm.default_btn_text") end end diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index 6cf680452..3f4628720 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -1,31 +1,31 @@ module ImportsHelper def mapping_label(mapping_class) { - "Import::AccountTypeMapping" => "Account Type", - "Import::AccountMapping" => "Account", - "Import::CategoryMapping" => "Category", - "Import::TagMapping" => "Tag" + "Import::AccountTypeMapping" => I18n.t("imports.mapping_labels.account_type"), + "Import::AccountMapping" => I18n.t("imports.mapping_labels.account"), + "Import::CategoryMapping" => I18n.t("imports.mapping_labels.category"), + "Import::TagMapping" => I18n.t("imports.mapping_labels.tag") }.fetch(mapping_class.name) end def import_col_label(key) { - date: "Date", - amount: "Amount", - name: "Name", - currency: "Currency", - category: "Category", - tags: "Tags", - account: "Account", - notes: "Notes", - qty: "Quantity", - ticker: "Ticker", - exchange: "Exchange", - price: "Price", - entity_type: "Type", - category_parent: "Parent category", - category_color: "Color", - category_icon: "Lucide icon" + date: I18n.t("imports.column_labels.date"), + amount: I18n.t("imports.column_labels.amount"), + name: I18n.t("imports.column_labels.name"), + currency: I18n.t("imports.column_labels.currency"), + category: I18n.t("imports.column_labels.category"), + tags: I18n.t("imports.column_labels.tags"), + account: I18n.t("imports.column_labels.account"), + notes: I18n.t("imports.column_labels.notes"), + qty: I18n.t("imports.column_labels.qty"), + ticker: I18n.t("imports.column_labels.ticker"), + exchange: I18n.t("imports.column_labels.exchange"), + price: I18n.t("imports.column_labels.price"), + entity_type: I18n.t("imports.column_labels.entity_type"), + category_parent: I18n.t("imports.column_labels.category_parent"), + category_color: I18n.t("imports.column_labels.category_color"), + category_icon: I18n.t("imports.column_labels.category_icon") }[key] end diff --git a/app/models/api_key.rb b/app/models/api_key.rb index ae24ccec3..4c12b4668 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -112,7 +112,7 @@ class ApiKey < ApplicationRecord def prevent_demo_monitoring_key_destroy! return unless demo_monitoring_key? - errors.add(:base, "Cannot destroy demo monitoring API key") + errors.add(:base, :cannot_destroy_demo_key) throw(:abort) end end diff --git a/app/models/category_import.rb b/app/models/category_import.rb index 15e3a6dd8..1bf4564bb 100644 --- a/app/models/category_import.rb +++ b/app/models/category_import.rb @@ -17,7 +17,7 @@ class CategoryImport < Import parent = ensure_placeholder_category(row.category_parent) if parent && parent == category - errors.add(:base, "Category '#{category.name}' cannot be its own parent") + errors.add(:base, :own_parent, name: category.name) raise ActiveRecord::RecordInvalid.new(self) end @@ -82,7 +82,7 @@ class CategoryImport < Import missing_headers = required_column_keys.map(&:to_s).reject { |key| header_for(key).present? } return if missing_headers.empty? - errors.add(:base, "Missing required columns: #{missing_headers.join(', ')}") + errors.add(:base, :missing_columns, columns: missing_headers.join(", ")) raise ActiveRecord::RecordInvalid.new(self) end diff --git a/app/models/import.rb b/app/models/import.rb index 2f1256ff4..ac3c5d5df 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -418,7 +418,7 @@ class Import < ApplicationRecord end if duplicate_headers.any? - errors.add(:base, "CSV headers normalize to duplicate columns: #{duplicate_headers.map { |headers| headers.join(', ') }.join('; ')}") + errors.add(:base, :duplicate_headers, columns: duplicate_headers.map { |headers| headers.join(", ") }.join("; ")) raise ActiveRecord::RecordInvalid, self end diff --git a/app/models/indexa_capital_item.rb b/app/models/indexa_capital_item.rb index ccea401b4..53bd562a8 100644 --- a/app/models/indexa_capital_item.rb +++ b/app/models/indexa_capital_item.rb @@ -176,6 +176,6 @@ class IndexaCapitalItem < ApplicationRecord def credentials_present_on_create return if credentials_configured? - errors.add(:base, "Either INDEXA_API_TOKEN env var or username/document/password credentials are required") + errors.add(:base, :credentials_required) end end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index ae2ecfeae..fa5922ae1 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -70,6 +70,6 @@ class PlaidAccount < ApplicationRecord # Plaid guarantees at least one of these. This validation is a sanity check for that guarantee. def has_balance return if current_balance.present? || available_balance.present? - errors.add(:base, "Plaid account must have either current or available balance") + errors.add(:base, :no_balance) end end diff --git a/app/models/recurring_transaction.rb b/app/models/recurring_transaction.rb index ffc2c75be..090d31549 100644 --- a/app/models/recurring_transaction.rb +++ b/app/models/recurring_transaction.rb @@ -24,7 +24,7 @@ class RecurringTransaction < ApplicationRecord def merchant_or_name_present if merchant_id.blank? && name.blank? - errors.add(:base, "Either merchant or name must be present") + errors.add(:base, :merchant_or_name_required) end end diff --git a/app/models/rule.rb b/app/models/rule.rb index b53f80f83..d5b89eef0 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -140,14 +140,14 @@ class Rule < ApplicationRecord return if new_record? && !actions.empty? if actions.reject(&:marked_for_destruction?).empty? - errors.add(:base, "must have at least one action") + errors.add(:base, :min_actions) end end def no_duplicate_actions action_types = actions.reject(&:marked_for_destruction?).map(&:action_type) - errors.add(:base, "Rule cannot have duplicate actions #{action_types.inspect}") if action_types.uniq.count != action_types.count + errors.add(:base, :duplicate_actions, types: action_types.inspect) if action_types.uniq.count != action_types.count end # Validation: To keep rules simple and easy to understand, we don't allow nested compound conditions. @@ -157,7 +157,7 @@ class Rule < ApplicationRecord conditions.each do |condition| if condition.compound? if condition.sub_conditions.any? { |sub_condition| sub_condition.compound? } - errors.add(:base, "Compound conditions cannot be nested") + errors.add(:base, :nested_conditions) end end end diff --git a/app/models/rule_import.rb b/app/models/rule_import.rb index 2fac4cb21..31cf4025a 100644 --- a/app/models/rule_import.rb +++ b/app/models/rule_import.rb @@ -115,7 +115,7 @@ class RuleImport < Import # Validate resource type unless resource_type == "transaction" - errors.add(:base, "Unsupported resource type: #{resource_type}") + errors.add(:base, :unsupported_resource_type, resource_type: resource_type) raise ActiveRecord::RecordInvalid.new(self) end @@ -124,13 +124,13 @@ class RuleImport < Import conditions_data = parse_json_safely(row.conditions, "conditions") actions_data = parse_json_safely(row.actions, "actions") rescue JSON::ParserError => e - errors.add(:base, "Invalid JSON in conditions or actions: #{e.message}") + errors.add(:base, :invalid_json, message: e.message) raise ActiveRecord::RecordInvalid.new(self) end # Validate we have at least one action if actions_data.empty? - errors.add(:base, "Rule must have at least one action") + errors.add(:base, :min_actions) raise ActiveRecord::RecordInvalid.new(self) end diff --git a/app/models/simplefin_account.rb b/app/models/simplefin_account.rb index bf9571edb..049834667 100644 --- a/app/models/simplefin_account.rb +++ b/app/models/simplefin_account.rb @@ -137,6 +137,6 @@ class SimplefinAccount < ApplicationRecord end def has_balance return if current_balance.present? || available_balance.present? - errors.add(:base, "SimpleFin account must have either current or available balance") + errors.add(:base, :no_balance) end end diff --git a/app/models/sophtron_account.rb b/app/models/sophtron_account.rb index e22769883..8179b7b84 100644 --- a/app/models/sophtron_account.rb +++ b/app/models/sophtron_account.rb @@ -158,7 +158,7 @@ class SophtronAccount < ApplicationRecord end def has_balance return if balance.present? || available_balance.present? - errors.add(:base, "Sophtron account must have either current or available balance") + errors.add(:base, :no_balance) end def first_present(hash, *keys) diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb index f9f2ef7c4..b5c9fe25b 100644 --- a/app/models/sso_provider.rb +++ b/app/models/sso_provider.rb @@ -90,7 +90,7 @@ class SsoProvider < ApplicationRecord idp_sso_url = settings&.dig("idp_sso_url") if idp_metadata_url.blank? && idp_sso_url.blank? - errors.add(:settings, "Either IdP Metadata URL or IdP SSO URL is required for SAML providers") + errors.add(:settings, :saml_url_required) end # If using manual config, require certificate @@ -99,17 +99,17 @@ class SsoProvider < ApplicationRecord idp_fingerprint = settings&.dig("idp_cert_fingerprint") if idp_cert.blank? && idp_fingerprint.blank? - errors.add(:settings, "Either IdP Certificate or Certificate Fingerprint is required when not using metadata URL") + errors.add(:settings, :saml_cert_required) end end # Validate URL formats if provided if idp_metadata_url.present? && !valid_url?(idp_metadata_url) - errors.add(:settings, "IdP Metadata URL must be a valid URL") + errors.add(:settings, :metadata_url_invalid) end if idp_sso_url.present? && !valid_url?(idp_sso_url) - errors.add(:settings, "IdP SSO URL must be a valid URL") + errors.add(:settings, :sso_url_invalid) end end diff --git a/app/models/transfer.rb b/app/models/transfer.rb index d2dcbf667..878e899be 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -107,12 +107,12 @@ class Transfer < ApplicationRecord private def transfer_has_different_accounts return unless inflow_transaction&.entry && outflow_transaction&.entry - errors.add(:base, "Must be from different accounts") if to_account == from_account + errors.add(:base, :different_accounts) if to_account == from_account end def transfer_has_same_family return unless inflow_transaction&.entry && outflow_transaction&.entry - errors.add(:base, "Must be from same family") unless to_account&.family == from_account&.family + errors.add(:base, :same_family) unless to_account&.family == from_account&.family end def transfer_has_opposite_amounts @@ -126,10 +126,10 @@ class Transfer < ApplicationRecord if inflow_entry.currency == outflow_entry.currency # For same currency, amounts must be exactly opposite - errors.add(:base, "Must have opposite amounts") if inflow_amount + outflow_amount != 0 + errors.add(:base, :opposite_amounts) if inflow_amount + outflow_amount != 0 else # For different currencies, just check the signs are opposite - errors.add(:base, "Must have opposite amounts") unless inflow_amount.negative? && outflow_amount.positive? + errors.add(:base, :opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive? end end @@ -138,6 +138,6 @@ class Transfer < ApplicationRecord date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs max_days = status == "confirmed" ? 30 : 4 - errors.add(:base, "Must be within #{max_days} days") if date_diff > max_days + errors.add(:base, :within_days, count: max_days) if date_diff > max_days end end diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index 59dca6c4e..bcc57a68d 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -54,7 +54,7 @@ <% if account.draft? %> <%= render DS::Link.new( - text: "Complete setup", + text: t(".complete_setup"), href: edit_account_path(account, return_to: return_to), variant: :outline, frame: :modal diff --git a/app/views/accounts/new/_container.html.erb b/app/views/accounts/new/_container.html.erb index 387a59f14..6c1c16112 100644 --- a/app/views/accounts/new/_container.html.erb +++ b/app/views/accounts/new/_container.html.erb @@ -29,13 +29,13 @@ diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index a36407a1f..5660695c9 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -7,11 +7,11 @@ <% unless @account.linked? %> <% if @account.permission_for(Current.user).in?([ :owner, :full_control ]) %> <%= render DS::Menu.new(variant: "button") do |menu| %> - <% menu.with_button(text: "New", variant: "secondary", icon: "plus") %> + <% menu.with_button(text: t(".new"), variant: "secondary", icon: "plus") %> <% menu.with_item( variant: "link", - text: "New balance", + text: t(".new_balance"), icon: "circle-dollar-sign", href: new_valuation_path(account_id: @account.id), data: { turbo_frame: :modal }) %> @@ -48,7 +48,7 @@ <%= icon("search") %> <%= hidden_field_tag :account_id, @account.id %> <%= form.search_field :search, - placeholder: "Search entries by name", + placeholder: t(".search_placeholder"), value: @q[:search], class: "form-field__input placeholder:text-sm placeholder:text-secondary", "data-auto-submit-form-target": "auto" %> diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index cafd97811..ddbad5a2b 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -17,7 +17,7 @@ <% end %> <% if account.draft? %> <%= render DS::Link.new( - text: "Complete setup", + text: t(".complete_setup"), href: edit_account_path(account), variant: :outline, size: :sm, diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb index cb577369e..de1b77626 100644 --- a/app/views/accounts/show/_menu.html.erb +++ b/app/views/accounts/show/_menu.html.erb @@ -3,9 +3,9 @@ <% permission = account.permission_for(Current.user) %> <%= render DS::Menu.new(testid: "account-menu") do |menu| %> <% if permission.in?([ :owner, :full_control ]) %> - <% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %> + <% menu.with_item(variant: "link", text: t(".edit"), href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %> <% end %> - <% menu.with_item(variant: "link", text: "Sharing", href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %> + <% menu.with_item(variant: "link", text: t(".sharing"), href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %> <% menu.with_item(variant: "link", text: t(".statements"), href: account_path(account, tab: "statements"), icon: "archive") %> <% if permission.in?([ :owner, :full_control ]) %> @@ -31,7 +31,7 @@ <% if account.owned_by?(Current.user) && !account.linked? %> <% menu.with_item( variant: "button", - text: "Delete account", + text: t(".delete_account"), href: account_path(account), method: :delete, icon: "trash-2", diff --git a/app/views/admin/sso_providers/_form.html.erb b/app/views/admin/sso_providers/_form.html.erb index 5a3527c3e..9845bab1a 100644 --- a/app/views/admin/sso_providers/_form.html.erb +++ b/app/views/admin/sso_providers/_form.html.erb @@ -6,7 +6,7 @@ <%= icon "alert-circle", class: "w-5 h-5 text-destructive mr-2 shrink-0" %>

- <%= pluralize(sso_provider.errors.count, "error") %> prohibited this provider from being saved: + <%= t("admin.sso_providers.form.errors_title", count: sso_provider.errors.count) %>

    <% sso_provider.errors.full_messages.each do |message| %> @@ -20,38 +20,38 @@ <%= styled_form_with model: [:admin, sso_provider], class: "space-y-6", data: { controller: "admin-sso-form" } do |form| %>
    -

    Basic Information

    +

    <%= t("admin.sso_providers.form.basic_information") %>

    <%= form.select :strategy, options_for_select([ - ["OpenID Connect", "openid_connect"], - ["SAML 2.0", "saml"], - ["Google OAuth2", "google_oauth2"], - ["GitHub", "github"] + [t("admin.sso_providers.form.strategy_openid_connect"), "openid_connect"], + [t("admin.sso_providers.form.strategy_saml"), "saml"], + [t("admin.sso_providers.form.strategy_google_oauth2"), "google_oauth2"], + [t("admin.sso_providers.form.strategy_github"), "github"] ], sso_provider.strategy), - { label: "Strategy" }, + { label: t("admin.sso_providers.form.strategy_label") }, { data: { action: "change->admin-sso-form#toggleFields" } } %> <%= form.text_field :name, - label: "Name", - placeholder: "e.g., keycloak, authentik", + label: t("admin.sso_providers.form.name_label"), + placeholder: t("admin.sso_providers.form.name_placeholder"), required: true, data: { action: "input->admin-sso-form#updateCallbackUrl" } %>
    -

    Unique identifier (lowercase, numbers, underscores only)

    +

    <%= t("admin.sso_providers.form.name_help") %>

    <%= form.text_field :label, - label: "Button Label", - placeholder: "e.g., Sign in with Keycloak", + label: t("admin.sso_providers.form.label_label"), + placeholder: t("admin.sso_providers.form.label_placeholder"), required: true %>
    <%= form.text_field :icon, - label: "Icon (optional)", - placeholder: "e.g., key, shield" %> -

    Lucide icon name for the login button

    + label: t("admin.sso_providers.form.icon_label"), + placeholder: t("admin.sso_providers.form.icon_placeholder") %> +

    <%= t("admin.sso_providers.form.icon_help") %>

    @@ -65,42 +65,42 @@
    -

    OAuth/OIDC Configuration

    +

    <%= t("admin.sso_providers.form.oauth_configuration") %>

    "> <%= form.text_field :issuer, - label: "Issuer URL", - placeholder: "https://your-idp.example.com/realms/your-realm", + label: t("admin.sso_providers.form.issuer_label"), + placeholder: t("admin.sso_providers.form.issuer_placeholder"), data: { action: "blur->admin-sso-form#validateIssuer" } %> -

    OIDC issuer URL (validates .well-known/openid-configuration)

    +

    <%= t("admin.sso_providers.form.issuer_help") %>

    <%= form.text_field :client_id, - label: "Client ID", - placeholder: "your-client-id", + label: t("admin.sso_providers.form.client_id_label"), + placeholder: t("admin.sso_providers.form.client_id_placeholder"), required: true %> <%= form.password_field :client_secret, - label: "Client Secret", - placeholder: sso_provider.persisted? ? "••••••••" : "your-client-secret", + label: t("admin.sso_providers.form.client_secret_label"), + placeholder: sso_provider.persisted? ? t("admin.sso_providers.form.client_secret_placeholder_existing") : t("admin.sso_providers.form.client_secret_placeholder_new"), required: !sso_provider.persisted? %> <% if sso_provider.persisted? %> -

    Leave blank to keep existing secret

    +

    <%= t("admin.sso_providers.form.client_secret_help_existing") %>

    <% end %>
    "> - +
    <%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %>
    -

    Configure this URL in your identity provider

    +

    <%= t("admin.sso_providers.form.redirect_uri_help") %>

    @@ -172,18 +172,18 @@
    - +
    <%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %>
    -

    Configure this URL as the Assertion Consumer Service URL in your IdP

    +

    <%= t("admin.sso_providers.form.saml_sp_callback_url_help") %>

@@ -282,8 +282,8 @@
- <%= link_to "Cancel", admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %> - <%= form.submit sso_provider.persisted? ? "Update Provider" : "Create Provider", + <%= link_to t("admin.sso_providers.form.cancel"), admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %> + <%= form.submit sso_provider.persisted? ? t("admin.sso_providers.form.update_provider") : t("admin.sso_providers.form.create_provider"), class: "px-4 py-2 button-bg-primary text-inverse rounded-lg text-sm font-medium hover:button-bg-primary-hover" %>
diff --git a/app/views/admin/sso_providers/index.html.erb b/app/views/admin/sso_providers/index.html.erb index c9ccfc6a1..c799d374d 100644 --- a/app/views/admin/sso_providers/index.html.erb +++ b/app/views/admin/sso_providers/index.html.erb @@ -1,14 +1,14 @@ -<%= content_for :page_title, "SSO Providers" %> +<%= content_for :page_title, t(".page_title") %>

- Manage single sign-on authentication providers for your instance. + <%= t(".description") %> <% unless FeatureFlags.db_sso_providers? %> - Changes require a server restart to take effect. + <%= t(".restart_required") %> <% end %>

- <%= settings_section title: "Configured Providers" do %> + <%= settings_section title: t(".configured_providers") do %> <% if @sso_providers.any? %>
<% @sso_providers.each do |provider| %> @@ -27,20 +27,20 @@
<% if provider.enabled? %> - Enabled + <%= t(".enabled") %> <% else %> - Disabled + <%= t(".disabled") %> <% end %> - <%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: "Edit" do %> + <%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: t(".edit") do %> <%= icon "pencil", class: "w-4 h-4" %> <% end %> - <%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? "Disable" : "Enable", form: { data: { turbo_confirm: "Are you sure you want to #{provider.enabled? ? 'disable' : 'enable'} this provider?" } } do %> + <%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? t(".disable") : t(".enable"), form: { data: { turbo_confirm: provider.enabled? ? t("admin.sso_providers.toggle.confirm_disable") : t("admin.sso_providers.toggle.confirm_enable") } } do %> <%= icon provider.enabled? ? "toggle-right" : "toggle-left", class: "w-4 h-4" %> <% end %> - <%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: "Delete", form: { data: { turbo_confirm: "Are you sure you want to delete this provider? This action cannot be undone." } } do %> + <%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: t(".delete"), form: { data: { turbo_confirm: t("admin.sso_providers.destroy.confirm") } } do %> <%= icon "trash-2", class: "w-4 h-4" %> <% end %>
@@ -50,14 +50,14 @@ <% else %>
<%= icon "key", class: "w-12 h-12 mx-auto text-secondary mb-3" %> -

No SSO providers configured yet.

+

<%= t(".no_providers_message") %>

<% end %>
<%= link_to new_admin_sso_provider_path, class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-secondary" do %> <%= icon "plus", class: "w-4 h-4" %> - Add Provider + <%= t(".add_provider") %> <% end %>
<% end %> @@ -100,26 +100,25 @@ <% end %> <% end %> - <%= settings_section title: "Configuration Mode", collapsible: true, open: false do %> + <%= settings_section title: t(".configuration_mode"), collapsible: true, open: false do %>
-

Database-backed providers

-

Load providers from database instead of YAML config

+

<%= t(".db_backed_providers") %>

+

<%= t(".db_backed_providers_description") %>

<% if FeatureFlags.db_sso_providers? %> - Enabled + <%= t(".enabled") %> <% else %> - Disabled + <%= t(".disabled") %> <% end %>

- Set AUTH_PROVIDERS_SOURCE=db to enable database-backed providers. - This allows changes without server restarts. + <%= t(".db_backed_providers_help_html") %>

<% end %> diff --git a/app/views/assistant_messages/_assistant_message.html.erb b/app/views/assistant_messages/_assistant_message.html.erb index 9768ee0d0..6b00a3c2d 100644 --- a/app/views/assistant_messages/_assistant_message.html.erb +++ b/app/views/assistant_messages/_assistant_message.html.erb @@ -9,7 +9,7 @@ <% elsif assistant_message.reasoning? %>
-

Assistant reasoning

+

<%= t(".assistant_reasoning") %>

<%= icon("chevron-down", class: "group-open:transform group-open:rotate-180") %>
diff --git a/app/views/assistant_messages/_tool_calls.html.erb b/app/views/assistant_messages/_tool_calls.html.erb index 59c149225..d2adace6a 100644 --- a/app/views/assistant_messages/_tool_calls.html.erb +++ b/app/views/assistant_messages/_tool_calls.html.erb @@ -3,15 +3,15 @@
<%= icon("chevron-right", class: "group-open:transform group-open:rotate-90") %> -

Tool Calls

+

<%= t(".tool_calls") %>

<% message.tool_calls.each do |tool_call| %>
-

Function:

+

<%= t(".function") %>

<%= tool_call.function_name %>

-

Arguments:

+

<%= t(".arguments") %>

<%= tool_call.function_arguments %>
<% end %> diff --git a/app/views/budget_categories/_confirm_button.html.erb b/app/views/budget_categories/_confirm_button.html.erb index a9d983317..730ebf1e3 100644 --- a/app/views/budget_categories/_confirm_button.html.erb +++ b/app/views/budget_categories/_confirm_button.html.erb @@ -1,6 +1,6 @@
<%= render DS::Button.new( - text: "Confirm", + text: t(".confirm"), variant: "primary", full_width: true, href: budget_path(budget), diff --git a/app/views/budget_categories/_no_categories.html.erb b/app/views/budget_categories/_no_categories.html.erb index 44d8915b1..ed949d2d9 100644 --- a/app/views/budget_categories/_no_categories.html.erb +++ b/app/views/budget_categories/_no_categories.html.erb @@ -1,18 +1,18 @@
-

Oops!

+

<%= t(".oops") %>

- You have not created or assigned any expense categories to your transactions yet. + <%= t(".no_categories_message") %>

<%= render DS::Button.new( - text: "Use defaults (recommended)", + text: t(".use_defaults"), href: bootstrap_categories_path, ) %> <%= render DS::Link.new( - text: "New category", + text: t(".new_category"), variant: "outline", icon: "plus", href: new_category_path, diff --git a/app/views/budget_categories/index.html.erb b/app/views/budget_categories/index.html.erb index d5ded43d7..049d5ede8 100644 --- a/app/views/budget_categories/index.html.erb +++ b/app/views/budget_categories/index.html.erb @@ -8,9 +8,9 @@
-

Edit your category budgets

+

<%= t(".title") %>

- Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized. + <%= t(".description") %>

diff --git a/app/views/budget_categories/show.html.erb b/app/views/budget_categories/show.html.erb index 154a7958f..6d8d64ed9 100644 --- a/app/views/budget_categories/show.html.erb +++ b/app/views/budget_categories/show.html.erb @@ -1,7 +1,7 @@ <%= render DS::Dialog.new(variant: :drawer) do |dialog| %> <% dialog.with_header do %>
-

Category

+

<%= t(".category") %>

<%= @budget_category.name %>

@@ -26,12 +26,12 @@ <% end %> <% dialog.with_body do %> - <% dialog.with_section(title: "Overview", open: true) do %> + <% dialog.with_section(title: t(".overview"), open: true) do %>
- <%= @budget_category.budget.start_date.strftime("%b %Y") %> spending + <%= t(".spending", date: @budget_category.budget.start_date.strftime("%b %Y")) %>
<%= format_money @budget_category.actual_spending_money %> @@ -40,30 +40,30 @@ <% if @budget_category.budget.initialized? %>
-
Status
+
<%= t(".status") %>
<% if @budget_category.available_to_spend.negative? %>
<%= icon "alert-circle", size: "sm", color: "destructive" %> <%= format_money @budget_category.available_to_spend_money.abs %> - overspent + <%= t(".overspent") %>
<% elsif @budget_category.available_to_spend.zero? %>
<%= icon "x-circle", size: "sm", color: "warning" %> <%= format_money @budget_category.available_to_spend_money %> - left + <%= t(".left") %>
<% else %>
<%= icon "check-circle", size: "sm", color: "success" %> <%= format_money @budget_category.available_to_spend_money %> - left + <%= t(".left") %>
<% end %>
-
Budgeted
+
<%= t(".budgeted") %>
<%= format_money @budget_category.budgeted_spending_money %>
@@ -71,14 +71,14 @@ <% end %>
-
Monthly average spending
+
<%= t(".monthly_average_spending") %>
<%= @budget_category.avg_monthly_expense_money.format %>
-
Monthly median spending
+
<%= t(".monthly_median_spending") %>
<%= @budget_category.median_monthly_expense_money.format %>
@@ -87,7 +87,7 @@
<% end %> - <% dialog.with_section(title: "Recent Transactions", open: true) do %> + <% dialog.with_section(title: t(".recent_transactions"), open: true) do %>
<% if @recent_transactions.any? %> @@ -120,7 +120,7 @@ <%= render DS::Link.new( - text: "View all category transactions", + text: t(".view_all_transactions"), variant: "outline", full_width: true, href: transactions_path(q: { @@ -132,7 +132,7 @@ ) %> <% else %>

- No transactions found for this budget period. + <%= t(".no_transactions") %>

<% end %>
diff --git a/app/views/budgets/_actuals_summary.html.erb b/app/views/budgets/_actuals_summary.html.erb index fe9509d9a..524fb0006 100644 --- a/app/views/budgets/_actuals_summary.html.erb +++ b/app/views/budgets/_actuals_summary.html.erb @@ -2,7 +2,7 @@
-

Income

+

<%= t(".income") %>

<%= budget.actual_income_money.format %> @@ -30,7 +30,7 @@
-

Expenses

+

<%= t(".expenses") %>

<%= budget.actual_spending_money.format %> diff --git a/app/views/budgets/_budget_donut.html.erb b/app/views/budgets/_budget_donut.html.erb index 8e0520b37..15c88046f 100644 --- a/app/views/budgets/_budget_donut.html.erb +++ b/app/views/budgets/_budget_donut.html.erb @@ -5,7 +5,7 @@
<% if budget.initialized? %>
- Spent + <%= t(".spent") %>
"> @@ -13,7 +13,7 @@
<%= render DS::Link.new( - text: "of #{budget.budgeted_spending_money.format}", + text: t(".of_budget", amount: budget.budgeted_spending_money.format), variant: "secondary", icon: "pencil", icon_position: "right", @@ -26,7 +26,7 @@
<%= render DS::Link.new( - text: "New budget", + text: t(".new_budget"), size: "sm", icon: "plus", href: edit_budget_path(budget) @@ -47,7 +47,7 @@

<%= render DS::Link.new( - text: "of #{bc.budgeted_spending_money.format(precision: 0)}", + text: t(".of_budget", amount: bc.budgeted_spending_money.format(precision: 0)), variant: "secondary", icon: "pencil", icon_position: "right", @@ -59,7 +59,7 @@ <% end %>
diff --git a/app/views/enable_banking_items/select_existing_account.html.erb b/app/views/enable_banking_items/select_existing_account.html.erb index 9303aa252..9d77b7071 100644 --- a/app/views/enable_banking_items/select_existing_account.html.erb +++ b/app/views/enable_banking_items/select_existing_account.html.erb @@ -1,15 +1,15 @@ <%# Modal: Link an existing manual account to a Enable Banking account %> <%= turbo_frame_tag "modal" do %> <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Link Enable Banking account") %> + <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %> <% if @available_enable_banking_accounts.blank? %>
-

All Enable Banking accounts appear to be linked already.

+

<%= t(".all_linked") %>

    -
  • If you just connected or synced, try again after the sync completes.
  • -
  • To link a different account, first unlink it from the account’s actions menu.
  • +
  • <%= t(".try_after_sync") %>
  • +
  • <%= t(".unlink_to_move") %>
<% else %> @@ -22,7 +22,7 @@
<%= eba.name.presence || eba.account_id %> - <%= eba.currency %> • Balance: <%= number_to_currency((eba.current_balance || 0), unit: eba.currency) %> + <%= eba.currency %> • <%= t(".balance") %>: <%= number_to_currency((eba.current_balance || 0), unit: eba.currency) %>
@@ -30,8 +30,8 @@
- <%= render DS::Button.new(text: "Link", variant: :primary, icon: "link-2", type: :submit) %> - <%= render DS::Link.new(text: "Cancel", variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %> + <%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %> + <%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
<% end %> <% end %> diff --git a/app/views/enable_banking_items/setup_accounts.html.erb b/app/views/enable_banking_items/setup_accounts.html.erb index 5da8e3016..97060d2c2 100644 --- a/app/views/enable_banking_items/setup_accounts.html.erb +++ b/app/views/enable_banking_items/setup_accounts.html.erb @@ -1,8 +1,8 @@ <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Set Up Your Enable Banking Accounts") do %> + <% dialog.with_header(title: t(".title")) do %>
<%= icon "building-2", class: "text-primary" %> - Choose the correct account types for your imported accounts + <%= t(".header_subtitle") %>
<% end %> @@ -13,7 +13,7 @@ data: { controller: "loading-button", action: "submit->loading-button#showLoading", - loading_button_loading_text_value: "Creating Accounts...", + loading_button_loading_text_value: t(".creating_accounts"), turbo_frame: "_top" }, class: "space-y-6" do |form| %> @@ -24,7 +24,7 @@ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>

- Choose the correct account type for each Enable Banking account: + <%= t(".choose_account_type") %>

    <% @account_type_options.reject { |_, type| type == "skip" }.each do |label, _| %> @@ -53,15 +53,15 @@ <%= icon "calendar", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>

    - Historical Data Range: + <%= t(".historical_data_range") %>

    <%= form.date_field :sync_start_date, - label: "Start syncing transactions from:", + label: t(".sync_start_date_label"), value: @enable_banking_item.sync_start_date || 3.months.ago.to_date, min: 2.years.ago.to_date, max: Date.current, class: "w-full max-w-xs rounded-md border border-primary px-3 py-2 text-sm bg-container-inset text-primary", - help_text: "Select how far back you want to sync transaction history. Maximum 2 years of history available." %> + help_text: t(".sync_start_date_help") %>
@@ -81,7 +81,7 @@

<%= enable_banking_account.account_type_display %>

<% end %> <% if enable_banking_account.current_balance.present? %> -

Balance: <%= number_to_currency(enable_banking_account.current_balance, unit: enable_banking_account.currency) %>

+

<%= t(".balance") %>: <%= number_to_currency(enable_banking_account.current_balance, unit: enable_banking_account.currency) %>

<% end %>
@@ -92,7 +92,7 @@ data-account-type-selector-account-id-value="<%= enable_banking_account.id %>" data-account-type-selector-suggested-subtype-value="<%= enable_banking_account.suggested_subtype %>">
- <%= label_tag "account_types[#{enable_banking_account.id}]", "Account Type:", + <%= label_tag "account_types[#{enable_banking_account.id}]", t(".account_type_label"), class: "block text-sm font-medium text-primary mb-2" %> <%= select_tag "account_types[#{enable_banking_account.id}]", options_for_select(@account_type_options, enable_banking_account.suggested_account_type || "skip"), @@ -115,7 +115,7 @@
<%= render DS::Button.new( - text: "Create Accounts", + text: t(".create_accounts"), variant: "primary", icon: "plus", type: "submit", @@ -123,7 +123,7 @@ data: { loading_button_target: "button" } ) %> <%= render DS::Link.new( - text: "Cancel", + text: t(".cancel"), variant: "secondary", href: accounts_path ) %> diff --git a/app/views/family_exports/new.html.erb b/app/views/family_exports/new.html.erb index d6942158b..9433eee2f 100644 --- a/app/views/family_exports/new.html.erb +++ b/app/views/family_exports/new.html.erb @@ -1,40 +1,40 @@ <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Export your data", subtitle: "Download all your financial data") %> + <% dialog.with_header(title: t(".dialog_title"), subtitle: t(".dialog_subtitle")) %> <% dialog.with_body do %>
-

What's included:

+

<%= t(".whats_included") %>

  • <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> - All accounts and balances + <%= t(".accounts_and_balances") %>
  • <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> - Transaction history + <%= t(".transaction_history") %>
  • <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> - Investment trades + <%= t(".investment_trades") %>
  • <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> - Categories, tags and rules + <%= t(".categories_tags_rules") %>

- Note: This export includes all of your data, but only some of the data can be imported back via the CSV import feature. We support account, transaction (with category and tags), and trade imports. Other account data cannot be imported and is for your records only. + <%= t(".note_label") %>: <%= t(".note_description") %>

<%= form_with url: family_exports_path, method: :post, class: "space-y-4" do |form| %>
- <%= link_to "Cancel", "#", class: "flex-1 text-center px-4 py-2 text-primary bg-surface border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %> - <%= form.submit "Export data", class: "flex-1 bg-inverse text-inverse rounded-lg px-4 py-2 cursor-pointer hover:bg-inverse-hover" %> + <%= link_to t(".cancel"), "#", class: "flex-1 text-center px-4 py-2 text-primary bg-surface border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %> + <%= form.submit t(".export_data"), class: "flex-1 bg-inverse text-inverse rounded-lg px-4 py-2 cursor-pointer hover:bg-inverse-hover" %>
<% end %>
diff --git a/app/views/family_merchants/_family_merchant.html.erb b/app/views/family_merchants/_family_merchant.html.erb index 8e3306df7..c0df9dfec 100644 --- a/app/views/family_merchants/_family_merchant.html.erb +++ b/app/views/family_merchants/_family_merchant.html.erb @@ -18,10 +18,10 @@ <%= render DS::Menu.new do |menu| %> - <% menu.with_item(variant: "link", text: "Edit", href: edit_family_merchant_path(family_merchant), icon: "pencil", data: { turbo_frame: "modal" }) %> + <% menu.with_item(variant: "link", text: t(".edit"), href: edit_family_merchant_path(family_merchant), icon: "pencil", data: { turbo_frame: "modal" }) %> <% menu.with_item( variant: "button", - text: "Delete", + text: t(".delete"), href: family_merchant_path(family_merchant), icon: "trash-2", method: :delete, diff --git a/app/views/holdings/_cost_basis_cell.html.erb b/app/views/holdings/_cost_basis_cell.html.erb index 8d595baee..0b82ca299 100644 --- a/app/views/holdings/_cost_basis_cell.html.erb +++ b/app/views/holdings/_cost_basis_cell.html.erb @@ -30,7 +30,7 @@ <% else %>
<%= icon "pencil", size: "xs" %> - Set + <%= t(".set") %>
<% end %> <% end %> diff --git a/app/views/ibkr_items/select_existing_account.html.erb b/app/views/ibkr_items/select_existing_account.html.erb index 3598da843..d1d27663b 100644 --- a/app/views/ibkr_items/select_existing_account.html.erb +++ b/app/views/ibkr_items/select_existing_account.html.erb @@ -1,14 +1,14 @@ <%= turbo_frame_tag "modal" do %> <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Link Interactive Brokers account") %> + <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %> <% if @available_ibkr_accounts.blank? %>
-

No unlinked Interactive Brokers accounts are available yet.

+

<%= t(".no_accounts_available") %>

    -
  • Run a sync from Settings > Providers after updating your Flex query.
  • -
  • Wait for the account discovery sync to finish.
  • +
  • <%= t(".run_sync_hint") %>
  • +
  • <%= t(".wait_for_sync") %>
<% else %> @@ -21,7 +21,7 @@
<%= ibkr_account.name.presence || ibkr_account.ibkr_account_id %> - <%= ibkr_account.currency %> • Balance: <%= number_to_currency((ibkr_account.current_balance || 0), unit: Money::Currency.new(ibkr_account.currency || "USD").symbol) %> + <%= ibkr_account.currency %> • <%= t(".balance") %>: <%= number_to_currency((ibkr_account.current_balance || 0), unit: Money::Currency.new(ibkr_account.currency || "USD").symbol) %>
@@ -29,8 +29,8 @@
- <%= render DS::Button.new(text: "Link", variant: :primary, icon: "link-2", type: :submit) %> - <%= render DS::Link.new(text: "Cancel", variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %> + <%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %> + <%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
<% end %> <% end %> diff --git a/app/views/impersonation_sessions/_super_admin_bar.html.erb b/app/views/impersonation_sessions/_super_admin_bar.html.erb index a09a8250f..718471b67 100644 --- a/app/views/impersonation_sessions/_super_admin_bar.html.erb +++ b/app/views/impersonation_sessions/_super_admin_bar.html.erb @@ -1,20 +1,20 @@
<%= icon "alert-triangle", size: "lg", color: "current", class: "mr-2" %> - Super Admin + <%= t(".super_admin") %>
- <%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %> + <%= link_to t(".jobs"), sidekiq_web_url, class: "text-white underline hover:text-gray-100" %>
<% if Current.session.active_impersonator_session.present? %>
- Impersonating: <%= Current.impersonated_user.email %> + <%= t(".impersonating") %>: <%= Current.impersonated_user.email %>
- <%= button_to "Leave", leave_impersonation_sessions_path, method: :delete, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> - <%= button_to "Terminate", complete_impersonation_session_path(Current.session.active_impersonator_session), method: :put, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> + <%= button_to t(".leave"), leave_impersonation_sessions_path, method: :delete, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> + <%= button_to t(".terminate"), complete_impersonation_session_path(Current.session.active_impersonator_session), method: :put, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<% else %> <% if Current.true_user.impersonator_support_sessions.in_progress.any? %> @@ -23,16 +23,16 @@ Current.true_user.impersonator_support_sessions.in_progress.map { |session| ["#{session.impersonated.email} (#{session.status})", session.id] }, - { prompt: "Join a session" }, + { prompt: t(".join_a_session") }, { class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono" } %> - <%= f.submit "Join", + <%= f.submit t(".join"), class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> <% end %> <% end %> <%= form_with model: ImpersonationSession.new, class: "flex items-center space-x-2" do |f| %> - <%= f.text_field :impersonated_id, class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono w-96", placeholder: "UUID", autocomplete: "off" %> - <%= f.submit "Request Impersonation", class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> + <%= f.text_field :impersonated_id, class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono w-96", placeholder: t(".uuid_placeholder"), autocomplete: "off" %> + <%= f.submit t(".request_impersonation"), class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> <% end %> <% end %>
diff --git a/app/views/import/cleans/show.html.erb b/app/views/import/cleans/show.html.erb index 14941acf8..834b9741a 100644 --- a/app/views/import/cleans/show.html.erb +++ b/app/views/import/cleans/show.html.erb @@ -14,11 +14,11 @@
<%= icon "check-circle", size: "sm", color: "success" %> -

Your data has been cleaned

+

<%= t(".data_cleaned") %>

<%= render DS::Link.new( - text: "Next step", + text: t(".next_step"), variant: "primary", href: @import.is_a?(PdfImport) ? import_path(@import) : import_confirm_path(@import), frame: :_top, @@ -35,8 +35,8 @@
- <%= link_to "All rows", import_clean_path(@import, per_page: params[:per_page], view: "all"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] != 'errors' ? 'bg-container' : ''}" %> - <%= link_to "Error rows", import_clean_path(@import, per_page: params[:per_page], view: "errors"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] == 'errors' ? 'bg-container' : ''}" %> + <%= link_to t(".all_rows"), import_clean_path(@import, per_page: params[:per_page], view: "all"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] != 'errors' ? 'bg-container' : ''}" %> + <%= link_to t(".error_rows"), import_clean_path(@import, per_page: params[:per_page], view: "errors"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] == 'errors' ? 'bg-container' : ''}" %>
diff --git a/app/views/import/configurations/_account_import.html.erb b/app/views/import/configurations/_account_import.html.erb index a8c4ec010..2b50e4cb7 100644 --- a/app/views/import/configurations/_account_import.html.erb +++ b/app/views/import/configurations/_account_import.html.erb @@ -1,18 +1,18 @@ <%# locals: (import:) %> <%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-4" do |form| %> - <%= form.select :entity_type_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Entity Type" } %> - <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name" }, required: true %> - <%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" }, required: true %> - <%= form.select :currency_col_label, import.csv_headers, { include_blank: "Default", label: "Currency" } %> + <%= form.select :entity_type_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".entity_type") } %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".name") }, required: true %> + <%= form.select :amount_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".balance") }, required: true %> + <%= form.select :currency_col_label, import.csv_headers, { include_blank: t(".default"), label: t(".currency") } %>
- <%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance Date" } %> + <%= form.select :date_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".balance_date") } %> <%= form.select :date_format, Family::DATE_FORMATS, - { label: "Date Format", prompt: "Select format" }, + { label: t(".date_format"), prompt: t(".select_format") }, required: @import.date_col_label.present? %>
- <%= form.submit "Apply configuration", disabled: import.complete? %> + <%= form.submit t(".apply_configuration"), disabled: import.complete? %> <% end %> diff --git a/app/views/import/configurations/_trade_import.html.erb b/app/views/import/configurations/_trade_import.html.erb index b9ac4685c..4ac559487 100644 --- a/app/views/import/configurations/_trade_import.html.erb +++ b/app/views/import/configurations/_trade_import.html.erb @@ -3,38 +3,38 @@ <%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-4" do |form| %>
- <%= form.select :date_col_label, import.csv_headers, { include_blank: "Select column", label: "Date" }, required: true %> + <%= form.select :date_col_label, import.csv_headers, { include_blank: t(".select_column"), label: t(".date_label") }, required: true %> <%= form.select :date_format, Family::DATE_FORMATS, { label: t(".date_format_label")}, label: true, required: true %>
- <%= form.select :qty_col_label, import.csv_headers, { include_blank: "Select column", label: "Quantity" }, required: true %> - <%= form.select :signage_convention, [["Buys are positive qty", "inflows_positive"], ["Buys are negative qty", "inflows_negative"]], label: true, required: true %> + <%= form.select :qty_col_label, import.csv_headers, { include_blank: t(".select_column"), label: t(".quantity_label") }, required: true %> + <%= form.select :signage_convention, [[t(".buys_are_positive"), "inflows_positive"], [t(".buys_are_negative"), "inflows_negative"]], label: true, required: true %>
- <%= form.select :currency_col_label, import.csv_headers, { include_blank: "Default", label: "Currency" } %> - <%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %> + <%= form.select :currency_col_label, import.csv_headers, { include_blank: t(".default"), label: t(".currency_label") } %> + <%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: t(".format_label"), prompt: t(".select_format") }, required: true %>
- <%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Select column", label: "Ticker" }, required: true %> - <%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Stock exchange code" } %> - <%= form.select :price_col_label, import.csv_headers, { include_blank: "Select column", label: "Price" }, required: true %> + <%= form.select :ticker_col_label, import.csv_headers, { include_blank: t(".select_column"), label: t(".ticker_label") }, required: true %> + <%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".stock_exchange_code_label") } %> + <%= form.select :price_col_label, import.csv_headers, { include_blank: t(".select_column"), label: t(".price_label") }, required: true %> <% unless import.account.present? %> - <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account" } %> + <%= form.select :account_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".account_label") } %> <% end %> - <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name" } %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".name_label") } %> <% unless Security.providers.any? %>

- Note: The security prices provider is not configured. Your trade imports will work, but Sure will not backfill price history. Please go to your settings to configure this. + <%= t(".note_label") %>: <%= t(".no_security_provider_warning") %>

<% end %>
- <%= form.submit "Apply configuration", disabled: import.complete? %> + <%= form.submit t(".apply_configuration"), disabled: import.complete? %> <% end %> diff --git a/app/views/import/configurations/_transaction_import.html.erb b/app/views/import/configurations/_transaction_import.html.erb index e0466d550..e6b927101 100644 --- a/app/views/import/configurations/_transaction_import.html.erb +++ b/app/views/import/configurations/_transaction_import.html.erb @@ -19,11 +19,11 @@
<%= form.select :date_col_label, import.csv_headers, - { label: "Date", prompt: "Select column" }, + { label: t(".date_label"), prompt: t(".select_column") }, required: true %> <%= form.select :date_format, Family::DATE_FORMATS, - { label: t(".date_format_label"), prompt: "Select format" }, + { label: t(".date_format_label"), prompt: t(".select_format") }, required: true %>
@@ -31,21 +31,21 @@
<%= form.select :amount_col_label, import.csv_headers, - { label: "Amount", container_class: "w-2/5", prompt: "Select column" }, + { label: t(".amount_label"), container_class: "w-2/5", prompt: t(".select_column") }, required: true %> <%= form.select :currency_col_label, import.csv_headers, - { include_blank: "Default", label: "Currency", container_class: "w-1/5" } %> + { include_blank: t(".default"), label: t(".currency_label"), container_class: "w-1/5" } %> <%= form.select :number_format, Import::NUMBER_FORMATS.keys, - { label: "Format", prompt: "Select format", container_class: "w-2/5" }, + { label: t(".format_label"), prompt: t(".select_format"), container_class: "w-2/5" }, required: true %>
<%# Amount Type Strategy %> <%= form.select :amount_type_strategy, Import::AMOUNT_TYPE_STRATEGIES.map { |strategy| [strategy.humanize, strategy] }, - { label: "Amount type strategy", prompt: "Select strategy" }, + { label: t(".amount_type_strategy_label"), prompt: t(".select_strategy") }, required: true, data: { action: "import#handleAmountTypeStrategyChange", @@ -58,8 +58,8 @@
<%= form.select :signage_convention, - [["Incomes are positive", "inflows_positive"], ["Incomes are negative", "inflows_negative"]], - { label: "Amount type", prompt: "Select convention" }, + [[t(".incomes_are_positive"), "inflows_positive"], [t(".incomes_are_negative"), "inflows_negative"]], + { label: t(".amount_type_label"), prompt: t(".select_convention") }, required: @import.amount_type_strategy == "signed_amount" %>
<% end %> @@ -70,32 +70,32 @@
- Set + <%= t(".set") %> <%= form.select :entity_type_col_label, import.csv_headers, - { prompt: "Select column", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" }, + { prompt: t(".select_column"), container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" }, required: @import.amount_type_strategy == "custom_column", data: { action: "import#handleAmountTypeChange" } %> - as amount type column + <%= t(".as_amount_type_column") %>
" data-import-target="amountTypeValue"> - Set + <%= t(".set") %> <%= form.select :amount_type_identifier_value, @import.selectable_amount_type_values, - { prompt: "Select value", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" }, + { prompt: t(".select_value"), container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" }, required: @import.amount_type_strategy == "custom_column", data: { action: "import#handleAmountTypeIdentifierChange" } %> - as identifier value + <%= t(".as_identifier_value") %>
" data-import-target="amountTypeInflowValue"> - Treat "<%= @import.amount_type_identifier_value %>" as + <%= t(".treat_as_html", value: @import.amount_type_identifier_value) %> <%= form.select :amount_type_inflow_value, - [["Income (inflow)", "inflows_positive"], ["Expense (outflow)", "inflows_negative"]], - { prompt: "Select type", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" }, + [[t(".income_inflow"), "inflows_positive"], [t(".expense_outflow"), "inflows_negative"]], + { prompt: t(".select_type"), container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" }, required: @import.amount_type_strategy == "custom_column" %>
@@ -105,21 +105,21 @@ <% unless import.account.present? %> <%= form.select :account_col_label, import.csv_headers, - { include_blank: "Leave empty", label: "Account" } %> + { include_blank: t(".leave_empty"), label: t(".account_label") } %> <% end %> <%= form.select :name_col_label, import.csv_headers, - { include_blank: "Leave empty", label: "Name" } %> + { include_blank: t(".leave_empty"), label: t(".name_label") } %> <%= form.select :category_col_label, import.csv_headers, - { include_blank: "Leave empty", label: "Category" } %> + { include_blank: t(".leave_empty"), label: t(".category_label") } %> <%= form.select :tags_col_label, import.csv_headers, - { include_blank: "Leave empty", label: "Tags" } %> + { include_blank: t(".leave_empty"), label: t(".tags_label") } %> <%= form.select :notes_col_label, import.csv_headers, - { include_blank: "Leave empty", label: "Notes" } %> + { include_blank: t(".leave_empty"), label: t(".notes_label") } %> - <%= form.submit "Apply configuration", disabled: import.complete? %> + <%= form.submit t(".apply_configuration"), disabled: import.complete? %> <% end %> diff --git a/app/views/import/confirms/_mappings.html.erb b/app/views/import/confirms/_mappings.html.erb index 2bb31ee8f..2cfb6ab46 100644 --- a/app/views/import/confirms/_mappings.html.erb +++ b/app/views/import/confirms/_mappings.html.erb @@ -12,7 +12,7 @@ <%= tag.p t(".no_accounts"), class: "text-sm" %> <%= render DS::Link.new( - text: "Create account", + text: t(".create_account"), variant: "primary", href: new_account_path(return_to: import_confirm_path(import)), frame: :modal @@ -60,7 +60,7 @@
<%= render DS::Link.new( - text: "Next", + text: t(".next"), variant: "primary", href: is_last_step ? import_path(import) : url_for(step: step_idx + 2), icon: "arrow-right", diff --git a/app/views/import/uploads/show.html.erb b/app/views/import/uploads/show.html.erb index cc9a94136..56a5e320b 100644 --- a/app/views/import/uploads/show.html.erb +++ b/app/views/import/uploads/show.html.erb @@ -93,7 +93,7 @@ <%# ── Standard CSV upload ── %>
- <%= render "imports/drag_drop_overlay", title: "Drop CSV to upload", subtitle: "Your file will be uploaded automatically" %> + <%= render "imports/drag_drop_overlay", title: t(".drop_csv_title"), subtitle: t(".drop_csv_subtitle") %>
@@ -103,8 +103,8 @@ <%= render DS::Tabs.new(active_tab: params[:tab] || "csv-upload", url_param_key: "tab", testid: "import-tabs") do |tabs| %> <% tabs.with_nav do |nav| %> - <% nav.with_btn(id: "csv-upload", label: "Upload CSV") %> - <% nav.with_btn(id: "csv-paste", label: "Copy & Paste") %> + <% nav.with_btn(id: "csv-upload", label: t(".upload_csv_tab")) %> + <% nav.with_btn(id: "csv-paste", label: t(".copy_paste_tab")) %> <% end %> <% tabs.with_panel(tab_id: "csv-upload") do %> @@ -112,7 +112,7 @@ <%= form.select :col_sep, Import::SEPARATORS, label: true %> <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> - <%= form.select :account_id, @import.family.accounts.visible.alphabetically.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> + <%= form.select :account_id, @import.family.accounts.visible.alphabetically.pluck(:name, :id), { label: t(".account_optional_label"), include_blank: t(".multi_account_import"), selected: @import.account_id } %> <% end %>
- <%= form.submit "Upload CSV", disabled: @import.complete? %> + <%= form.submit t(".upload_csv_button"), disabled: @import.complete? %> <% end %> <% end %> @@ -144,16 +144,16 @@ <%= form.select :col_sep, Import::SEPARATORS, label: true %> <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> - <%= form.select :account_id, @import.family.accounts.visible.alphabetically.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> + <%= form.select :account_id, @import.family.accounts.visible.alphabetically.pluck(:name, :id), { label: t(".account_optional_label"), include_blank: t(".multi_account_import"), selected: @import.account_id } %> <% end %> <%= form.text_area :raw_file_str, rows: 10, required: true, - placeholder: "Paste your CSV file contents here", + placeholder: t(".paste_csv_placeholder"), "data-auto-submit-form-target": "auto" %> - <%= form.submit "Upload CSV", disabled: @import.complete? %> + <%= form.submit t(".upload_csv_button"), disabled: @import.complete? %> <% end %> <% end %> <% end %> @@ -161,7 +161,7 @@
- <%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format + <%= link_to t(".download_sample_csv"), "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> <%= t(".to_see_format") %>
diff --git a/app/views/imports/_failure.html.erb b/app/views/imports/_failure.html.erb index ee9e8b9fc..3cdc4008f 100644 --- a/app/views/imports/_failure.html.erb +++ b/app/views/imports/_failure.html.erb @@ -7,10 +7,10 @@
-

Import failed

-

Please check that your file format, for any errors and that all required fields are filled, then come back and try again.

+

<%= t(".title") %>

+

<%= t(".description") %>

- <%= render DS::Button.new(text: "Try again", href: publish_import_path(import), full_width: true) %> + <%= render DS::Button.new(text: t(".try_again"), href: publish_import_path(import), full_width: true) %>
diff --git a/app/views/imports/_importing.html.erb b/app/views/imports/_importing.html.erb index 546701705..7fb8ed100 100644 --- a/app/views/imports/_importing.html.erb +++ b/app/views/imports/_importing.html.erb @@ -7,13 +7,13 @@
-

Import in progress

-

Your import is in progress. Check the imports menu for status updates or click 'Check Status' to refresh the page for updates. Feel free to continue using the app.

+

<%= t(".title") %>

+

<%= t(".description") %>

- <%= render DS::Link.new(text: "Check status", href: import_path(import), variant: "primary", full_width: true) %> - <%= render DS::Link.new(text: "Back to dashboard", href: root_path, variant: "secondary", full_width: true) %> + <%= render DS::Link.new(text: t(".check_status"), href: import_path(import), variant: "primary", full_width: true) %> + <%= render DS::Link.new(text: t(".back_to_dashboard"), href: root_path, variant: "secondary", full_width: true) %>
diff --git a/app/views/imports/_revert_failure.html.erb b/app/views/imports/_revert_failure.html.erb index 9d64e7a76..6c4872d06 100644 --- a/app/views/imports/_revert_failure.html.erb +++ b/app/views/imports/_revert_failure.html.erb @@ -7,12 +7,12 @@
-

Reverting import failed

-

Please try again

+

<%= t(".title") %>

+

<%= t(".description") %>

<%= render DS::Button.new( - text: "Try again", + text: t(".try_again"), full_width: true, href: revert_import_path(import) ) %> diff --git a/app/views/imports/_success.html.erb b/app/views/imports/_success.html.erb index c0849e94b..c3bc2d1eb 100644 --- a/app/views/imports/_success.html.erb +++ b/app/views/imports/_success.html.erb @@ -7,12 +7,12 @@
-

Import successful

-

Your imported data has been successfully added to the app and is now ready for use.

+

<%= t(".title") %>

+

<%= t(".description") %>

<%= render DS::Link.new( - text: "Back to dashboard", + text: t(".back_to_dashboard"), variant: "primary", full_width: true, href: root_path diff --git a/app/views/layouts/shared/_confirm_dialog.html.erb b/app/views/layouts/shared/_confirm_dialog.html.erb index 03e923818..a25c36f72 100644 --- a/app/views/layouts/shared/_confirm_dialog.html.erb +++ b/app/views/layouts/shared/_confirm_dialog.html.erb @@ -5,17 +5,17 @@
-

Are you sure?

+

<%= t(".are_you_sure") %>

<%= icon("x", as_button: true, type: "submit", value: "cancel") %>
-

This action cannot be undone.

+

<%= t(".cannot_be_undone") %>

<% ["primary", "outline-destructive", "destructive"].each do |variant| %> <%= render DS::Button.new( - text: "Confirm", + text: t(".confirm"), variant: variant, autofocus: true, full_width: true, diff --git a/app/views/loans/tabs/_overview.html.erb b/app/views/loans/tabs/_overview.html.erb index 41167e6fb..2c6d3810a 100644 --- a/app/views/loans/tabs/_overview.html.erb +++ b/app/views/loans/tabs/_overview.html.erb @@ -46,7 +46,7 @@
<%= render DS::Link.new( - text: "Edit loan details", + text: t(".edit_loan_details"), variant: "ghost", href: edit_loan_path(account), frame: :modal diff --git a/app/views/lunchflow_items/_api_error.html.erb b/app/views/lunchflow_items/_api_error.html.erb index b1d6cee13..50050e35a 100644 --- a/app/views/lunchflow_items/_api_error.html.erb +++ b/app/views/lunchflow_items/_api_error.html.erb @@ -1,22 +1,22 @@ <%# locals: (error_message:, return_path:) %> <%= turbo_frame_tag "modal" do %> <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Lunch Flow Connection Error") %> + <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %>
<%= render DS::Alert.new( - title: "Unable to connect to Lunch Flow", + title: t(".unable_to_connect"), message: error_message, variant: :error ) %>
-

Common Issues:

+

<%= t(".common_issues") %>

    -
  • Invalid API Key: Check your API key in Provider Settings
  • -
  • Expired Credentials: Generate a new API key from Lunch Flow
  • -
  • Network Issue: Check your internet connection
  • -
  • Service Down: Lunch Flow API may be temporarily unavailable
  • +
  • <%= t(".invalid_api_key_label") %>: <%= t(".invalid_api_key_desc") %>
  • +
  • <%= t(".expired_credentials_label") %>: <%= t(".expired_credentials_desc") %>
  • +
  • <%= t(".network_issue_label") %>: <%= t(".network_issue_desc") %>
  • +
  • <%= t(".service_down_label") %>: <%= t(".service_down_desc") %>
@@ -24,7 +24,7 @@ <%= link_to settings_providers_path, class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors", data: { turbo: false } do %> - Check Provider Settings + <%= t(".check_provider_settings") %> <% end %>
diff --git a/app/views/lunchflow_items/_setup_required.html.erb b/app/views/lunchflow_items/_setup_required.html.erb index afd03df41..1773e81ef 100644 --- a/app/views/lunchflow_items/_setup_required.html.erb +++ b/app/views/lunchflow_items/_setup_required.html.erb @@ -1,21 +1,21 @@ <%= turbo_frame_tag "modal" do %> <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Lunch Flow Setup Required") %> + <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %>
<%= render DS::Alert.new( - title: "API Key Not Configured", - message: "Before you can link Lunch Flow accounts, you need to configure your Lunch Flow API key.", + title: t(".api_key_not_configured"), + message: t(".api_key_description"), variant: :warning ) %>
-

Setup Steps:

+

<%= t(".setup_steps_title") %>

    -
  1. Go to Settings → Providers
  2. -
  3. Find the Lunch Flow section
  4. -
  5. Enter your Lunch Flow API key
  6. -
  7. Return here to link your accounts
  8. +
  9. <%= t(".setup_step_1_html") %>
  10. +
  11. <%= t(".setup_step_2_html") %>
  12. +
  13. <%= t(".setup_step_3") %>
  14. +
  15. <%= t(".setup_step_4") %>
@@ -23,7 +23,7 @@ <%= link_to settings_providers_path, class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors", data: { turbo: false } do %> - Go to Provider Settings + <%= t(".go_to_provider_settings") %> <% end %>
diff --git a/app/views/mercury_items/_api_error.html.erb b/app/views/mercury_items/_api_error.html.erb index 4e4124635..0f5696b14 100644 --- a/app/views/mercury_items/_api_error.html.erb +++ b/app/views/mercury_items/_api_error.html.erb @@ -1,25 +1,25 @@ <%# locals: (error_message:, return_path:) %> <%= turbo_frame_tag "modal" do %> <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Mercury Connection Error") %> + <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %>
<%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %>
-

Unable to connect to Mercury

+

<%= t(".unable_to_connect") %>

<%= error_message %>

-

Common Issues:

+

<%= t(".common_issues") %>

    -
  • Invalid API Token: Check your API token in Provider Settings
  • -
  • Expired Credentials: Generate a new API token from Mercury
  • -
  • Insufficient Permissions: Ensure your token has read-only access
  • -
  • Network Issue: Check your internet connection
  • -
  • Service Down: Mercury API may be temporarily unavailable
  • +
  • <%= t(".invalid_api_token_label") %>: <%= t(".invalid_api_token_desc") %>
  • +
  • <%= t(".expired_credentials_label") %>: <%= t(".expired_credentials_desc") %>
  • +
  • <%= t(".insufficient_permissions_label") %>: <%= t(".insufficient_permissions_desc") %>
  • +
  • <%= t(".network_issue_label") %>: <%= t(".network_issue_desc") %>
  • +
  • <%= t(".service_down_label") %>: <%= t(".service_down_desc") %>
@@ -27,7 +27,7 @@ <%= link_to settings_providers_path, class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors", data: { turbo: false } do %> - Check Provider Settings + <%= t(".check_provider_settings") %> <% end %>
diff --git a/app/views/mercury_items/_setup_required.html.erb b/app/views/mercury_items/_setup_required.html.erb index 069847729..5711d968d 100644 --- a/app/views/mercury_items/_setup_required.html.erb +++ b/app/views/mercury_items/_setup_required.html.erb @@ -1,23 +1,23 @@ <%= turbo_frame_tag "modal" do %> <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Mercury Setup Required") %> + <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %>
<%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %>
-

API Token Not Configured

-

Before you can link Mercury accounts, you need to configure your Mercury API token.

+

<%= t(".api_token_not_configured") %>

+

<%= t(".api_token_description") %>

-

Setup Steps:

+

<%= t(".setup_steps_title") %>

    -
  1. Go to Settings > Providers
  2. -
  3. Find the Mercury section
  4. -
  5. Enter your Mercury API token
  6. -
  7. Return here to link your accounts
  8. +
  9. <%= t(".setup_step_1_html") %>
  10. +
  11. <%= t(".setup_step_2_html") %>
  12. +
  13. <%= t(".setup_step_3") %>
  14. +
  15. <%= t(".setup_step_4") %>
@@ -25,7 +25,7 @@ <%= link_to settings_providers_path, class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors", data: { turbo: false } do %> - Go to Provider Settings + <%= t(".go_to_provider_settings") %> <% end %>
diff --git a/app/views/messages/_chat_form.html.erb b/app/views/messages/_chat_form.html.erb index 85f686f57..1fbc57c82 100644 --- a/app/views/messages/_chat_form.html.erb +++ b/app/views/messages/_chat_form.html.erb @@ -10,7 +10,7 @@ <%# In the future, this will be a dropdown with different AI models %> <%= f.hidden_field :ai_model, value: default_ai_model %> - <%= f.text_area :content, placeholder: "Ask anything ...", value: message_hint, + <%= f.text_area :content, placeholder: t(".placeholder"), value: message_hint, class: "w-full border-0 focus:ring-0 text-sm resize-none px-1 bg-transparent", data: { chat_target: "input", action: "input->chat#autoResize keydown->chat#handleInputKeyDown" }, rows: 1 %> @@ -27,5 +27,5 @@
<% end %> -

AI responses are informational only. Not financial advice!

+

<%= t(".disclaimer") %>

diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index fafc75e2f..b2ff7d632 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -37,13 +37,13 @@
-
Name
+
<%= t(".name") %>
-

Weight

+

<%= t(".weight") %>

-

Value

+

<%= t(".value") %>

diff --git a/app/views/pages/feedback.html.erb b/app/views/pages/feedback.html.erb index 71739e2c8..29031ce01 100644 --- a/app/views/pages/feedback.html.erb +++ b/app/views/pages/feedback.html.erb @@ -1,22 +1,22 @@ -<%= content_for :page_title, "Feedback" %> +<%= content_for :page_title, t(".title") %>
-

Leave feedback

-

Let us know if you have any specific feedback. Feel free to include links to videos or screenshots.

+

<%= t(".heading") %>

+

<%= t(".description") %>

<%= link_to "https://github.com/we-promise/sure/discussions/categories/feature-requests", target: "_blank", rel: "noopener noreferrer", class: "w-full md:w-1/3 flex flex-col items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-container-hover" do %> <%= image_tag "github-icon.svg", class: "w-8 h-8 mb-2" %> - Write a feature request + <%= t(".feature_request") %> <% end %> <%= link_to "https://github.com/we-promise/sure/issues/new?assignees=&labels=bug&template=bug_report.md&title=", target: "_blank", rel: "noopener noreferrer", class: "w-full md:w-1/3 flex flex-col items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-container-hover" do %> <%= image_tag "github-icon.svg", class: "w-8 h-8 mb-2" %> - File a bug report + <%= t(".bug_report") %> <% end %> <%= link_to "https://discord.gg/36ZGBsxYEK", target: "_blank", rel: "noopener noreferrer", class: "w-full md:w-1/3 flex flex-col items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-container-hover" do %> <%= image_tag "discord-icon.svg", class: "w-8 h-8 mb-2" %> - Discuss <%= product_name %> with others + <%= t(".discuss", product: product_name) %> <% end %>
diff --git a/app/views/pages/intro.html.erb b/app/views/pages/intro.html.erb index 6ebceb315..7378ccdeb 100644 --- a/app/views/pages/intro.html.erb +++ b/app/views/pages/intro.html.erb @@ -1,6 +1,6 @@ <% content_for :page_header do %>
-

Welcome!

+

<%= t(".welcome") %>


<% end %> @@ -10,12 +10,12 @@
<%= image_tag "logomark-color.svg", class: "w-16 h-16" %>
-

Intro experience coming soon

+

<%= t(".coming_soon") %>

- We're building a richer onboarding journey to learn about your goals, milestones, and day-to-day needs. For now, head over to the chat sidebar to start a conversation with Sure and let us know where you are in your financial journey. + <%= t(".description") %>

- <%= link_to "Start chatting", chats_path, class: "inline-flex items-center gap-2 px-4 py-2 rounded-lg button-bg-primary text-inverse font-medium" %> + <%= link_to t(".start_chatting"), chats_path, class: "inline-flex items-center gap-2 px-4 py-2 rounded-lg button-bg-primary text-inverse font-medium" %>
diff --git a/app/views/pages/redis_configuration_error.html.erb b/app/views/pages/redis_configuration_error.html.erb index a95e00862..8a1a6411f 100644 --- a/app/views/pages/redis_configuration_error.html.erb +++ b/app/views/pages/redis_configuration_error.html.erb @@ -1,4 +1,4 @@ -<% content_for :title, "Redis Configuration Required - Sure" %> +<% content_for :title, t(".page_title") %>
@@ -8,8 +8,8 @@
<%= icon "alert-triangle", class: "w-8 h-8 text-red-600" %>
-

Redis Configuration Required

-

Your self-hosted Sure installation needs Redis to be properly configured.

+

<%= t(".heading") %>

+

<%= t(".subheading") %>

@@ -18,8 +18,8 @@
<%= icon "info", class: "w-5 h-5 text-amber-600 mt-0.5 mr-3 flex-shrink-0" %>
-

Why is Redis required?

-

Sure uses Redis to power Sidekiq background jobs for tasks like syncing account data, processing imports, and other background operations that keep your financial data up to date.

+

<%= t(".why_required_title") %>

+

<%= t(".why_required_body") %>

@@ -27,7 +27,7 @@
<%= render DS::Link.new( - text: "View Setup Guide", + text: t(".view_setup_guide"), href: "https://github.com/we-promise/sure/blob/main/docs/hosting/docker.md", variant: "primary", size: "lg", @@ -36,16 +36,16 @@ target: "_blank", rel: "noopener noreferrer" ) %> -

Follow our complete Docker setup guide to configure Redis

+

<%= t(".setup_guide_hint") %>

-

Once you've configured Redis, refresh this page to continue.

+

<%= t(".refresh_hint") %>

<%= render DS::Button.new( - text: "Refresh Page", + text: t(".refresh_page"), variant: "secondary", icon: "refresh-cw", type: "button", diff --git a/app/views/properties/_overview_fields.html.erb b/app/views/properties/_overview_fields.html.erb index 0fe59023d..d641c7bdf 100644 --- a/app/views/properties/_overview_fields.html.erb +++ b/app/views/properties/_overview_fields.html.erb @@ -2,8 +2,8 @@
<%= form.text_field :name, - label: "Name", - placeholder: "Vacation home", + label: t(".name_label"), + placeholder: t(".name_placeholder"), required: true %> <%= form.hidden_field :accountable_type, value: "Property" %> @@ -11,24 +11,24 @@ <%= form.fields_for :accountable do |property_form| %> <%= property_form.select :subtype, Property::SUBTYPES.map { |k, v| [v[:long], k] }, - { prompt: "Select type", label: "Property type" }, required: true %> + { prompt: t(".subtype_prompt"), label: t(".property_type_label") }, required: true %>
<%= property_form.number_field :year_built, - label: "Year Built (optional)", - placeholder: "1990", + label: t(".year_built_label"), + placeholder: t(".year_built_placeholder"), min: 1500, max: Time.current.year %>
<%= property_form.number_field :area_value, - label: "Area (optional)", - placeholder: "1200", + label: t(".area_label"), + placeholder: t(".area_placeholder"), min: 0 %> <%= property_form.select :area_unit, - [["Square Feet", "sqft"], ["Square Meters", "sqm"]], - { label: "Area Unit" } %> + [[t(".square_feet"), "sqft"], [t(".square_meters"), "sqm"]], + { label: t(".area_unit_label") } %>
<% end %> diff --git a/app/views/properties/address.html.erb b/app/views/properties/address.html.erb index 834f7c95c..489ab62b1 100644 --- a/app/views/properties/address.html.erb +++ b/app/views/properties/address.html.erb @@ -1,5 +1,5 @@ <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Enter property manually") %> + <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %>
@@ -13,25 +13,25 @@ <%= form.fields_for :address do |address_form| %> <%= address_form.text_field :line1, - label: "Address Line 1", - placeholder: "123 Main Street" %> + label: t(".address_line1_label"), + placeholder: t(".address_line1_placeholder") %>
<%= address_form.text_field :locality, - label: "City", - placeholder: "San Francisco" %> + label: t(".city_label"), + placeholder: t(".city_placeholder") %> <%= address_form.text_field :region, - label: "State/Region", - placeholder: "CA" %> + label: t(".state_region_label"), + placeholder: t(".state_region_placeholder") %>
<%= address_form.text_field :postal_code, - label: "Postal Code", - placeholder: "12345" %> + label: t(".postal_code_label"), + placeholder: t(".postal_code_placeholder") %> <%= address_form.text_field :country, - label: "Country", - placeholder: "USA" %> + label: t(".country_label"), + placeholder: t(".country_placeholder") %>
<% end %>
@@ -39,7 +39,7 @@
<%= render DS::Button.new( - text: "Save", + text: t(".save"), variant: "primary", ) %>
diff --git a/app/views/properties/balances.html.erb b/app/views/properties/balances.html.erb index a25f77936..3cd96041d 100644 --- a/app/views/properties/balances.html.erb +++ b/app/views/properties/balances.html.erb @@ -1,5 +1,5 @@ <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Enter property manually") %> + <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %>
<%= render "properties/form_tabs", account: @account, active_tab: "value" %> @@ -11,15 +11,15 @@ <%= render "properties/form_alert", notice: @success_message, error: @error_message %> <%= form.money_field :balance, - label: "Estimated market value", - label_tooltip: "The estimated market value of your property. This number can often be found on sites like Zillow or Redfin, and is never an exact number.", + label: t(".market_value_label"), + label_tooltip: t(".market_value_tooltip"), placeholder: "0" %>
<%= render DS::Button.new( - text: @account.active? ? "Save" : "Next", + text: @account.active? ? t(".save") : t(".next"), variant: "primary", ) %>
diff --git a/app/views/properties/new.html.erb b/app/views/properties/new.html.erb index 03feb04a0..32ab6d960 100644 --- a/app/views/properties/new.html.erb +++ b/app/views/properties/new.html.erb @@ -1,5 +1,5 @@ <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Enter property manually") %> + <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %>
@@ -16,7 +16,7 @@
<%= render DS::Button.new( - text: "Next", + text: t(".next"), variant: "primary", ) %>
diff --git a/app/views/properties/tabs/_overview.html.erb b/app/views/properties/tabs/_overview.html.erb index 3a33e092f..df3417443 100644 --- a/app/views/properties/tabs/_overview.html.erb +++ b/app/views/properties/tabs/_overview.html.erb @@ -30,7 +30,7 @@
<%= render DS::Link.new( - text: "Edit account details", + text: t(".edit_account_details"), href: edit_property_path(account), variant: "ghost", frame: :modal diff --git a/app/views/reports/_investment_flows.html.erb b/app/views/reports/_investment_flows.html.erb index 83db092e2..ee773b994 100644 --- a/app/views/reports/_investment_flows.html.erb +++ b/app/views/reports/_investment_flows.html.erb @@ -1,7 +1,7 @@ <%# locals: (investment_flows:) %>

- Track money flowing into and out of your investment accounts through contributions and withdrawals. + <%= t(".description") %>

@@ -9,36 +9,36 @@
<%= icon("trending-up", size: "sm", class: "text-green-600") %> -

Contributions

+

<%= t(".contributions") %>

<%= format_money(investment_flows.contributions) %>
-

Money added to investments

+

<%= t(".contributions_description") %>

<%= icon("trending-down", size: "sm", class: "text-orange-600") %> -

Withdrawals

+

<%= t(".withdrawals") %>

<%= format_money(investment_flows.withdrawals) %>
-

Money withdrawn from investments

+

<%= t(".withdrawals_description") %>

<%= icon("arrow-right-left", size: "sm", class: "text-primary") %> -

Net Flow

+

<%= t(".net_flow") %>

<%= format_money(investment_flows.net_flow) %>
-

Total net change

+

<%= t(".net_flow_description") %>

diff --git a/app/views/rule/conditions/_condition_group.html.erb b/app/views/rule/conditions/_condition_group.html.erb index 54f056bae..09161462f 100644 --- a/app/views/rule/conditions/_condition_group.html.erb +++ b/app/views/rule/conditions/_condition_group.html.erb @@ -11,11 +11,11 @@
<%# Show prefix on condition groups, except the first one %>
- and + <%= t(".and_prefix") %>
-

match

- <%= form.select :operator, [["all", "and"], ["any", "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %> -

of the following conditions

+

<%= t(".match") %>

+ <%= form.select :operator, [[t(".all"), "and"], [t(".any"), "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %> +

<%= t(".of_the_following_conditions") %>

<%= icon( @@ -40,7 +40,7 @@ <%= render DS::Button.new( - text: "Add condition", + text: t(".add_condition"), leading_icon: "plus", variant: "ghost", type: "button", diff --git a/app/views/rules/_form.html.erb b/app/views/rules/_form.html.erb index 2f4f1fe18..7c942a35f 100644 --- a/app/views/rules/_form.html.erb +++ b/app/views/rules/_form.html.erb @@ -12,10 +12,10 @@
<%= icon "tag", size: "sm" %> -

Rule name (optional)

+

<%= t(".rule_name_label") %>

- <%= f.text_field :name, placeholder: "Enter a name for this rule", class: "form-field__input" %> + <%= f.text_field :name, placeholder: t(".rule_name_placeholder"), class: "form-field__input" %>
@@ -50,15 +50,15 @@
- <%= render DS::Button.new(text: "Add condition", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addCondition" }) %> - <%= render DS::Button.new(text: "Add condition group", icon: "copy-plus", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %> + <%= render DS::Button.new(text: t(".add_condition"), icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addCondition" }) %> + <%= render DS::Button.new(text: t(".add_condition_group"), icon: "copy-plus", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %>
-

THEN

+

<%= t(".then") %>

<%# Action template, used by Stimulus controller to add new actions dynamically %> @@ -75,7 +75,7 @@ <% end %> - <%= render DS::Button.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %> + <%= render DS::Button.new(text: t(".add_action"), icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %>
@@ -87,13 +87,13 @@
<%= f.radio_button :effective_date_enabled, false, checked: rule.effective_date.nil?, data: { action: "rules#clearEffectiveDate" } %> - <%= f.label :effective_date_enabled_false, "All past and future #{rule.resource_type}s", class: "text-sm text-primary" %> + <%= f.label :effective_date_enabled_false, t(".all_past_and_future", resource: rule.resource_type.pluralize), class: "text-sm text-primary" %>
<%= f.radio_button :effective_date_enabled, true, checked: rule.effective_date.present? %> - <%= f.label :effective_date_enabled_true, "Starting from", class: "text-sm text-primary" %> + <%= f.label :effective_date_enabled_true, t(".starting_from"), class: "text-sm text-primary" %>
<%= f.date_field :effective_date, container_class: "w-fit", data: { rules_target: "effectiveDateInput" } %> diff --git a/app/views/rules/_rule.html.erb b/app/views/rules/_rule.html.erb index 2e882e4ba..3106e8b4f 100644 --- a/app/views/rules/_rule.html.erb +++ b/app/views/rules/_rule.html.erb @@ -21,14 +21,14 @@ <% end %> <% if additional_condition_count.positive? %> - and <%= additional_condition_count %> more <%= additional_condition_count == 1 ? "condition" : "conditions" %> + <%= t(".and_more_conditions", count: additional_condition_count) %> <% end %>

<% end %>
- THEN + <%= t(".then") %>

@@ -36,14 +36,14 @@ <%= t("rules.no_action") %> <% else %> <% if rule.actions.first.value && rule.actions.first.options %> - <%= rule.actions.first.executor.label %> to <%= rule.actions.first.value_display %> + <%= t(".action_label_to", label: rule.actions.first.executor.label, value: rule.actions.first.value_display) %> <% else %> <%= rule.actions.first.executor.label %> <% end %> <% end %> <% if rule.actions.count > 1 %> - and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %> + <%= t(".and_more_actions", count: rule.actions.count - 1) %> <% end %>

@@ -54,9 +54,9 @@

<% if rule.effective_date.nil? %> - All past and future <%= rule.resource_type.pluralize %> + <%= t(".all_past_and_future", resource: rule.resource_type.pluralize) %> <% else %> - <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime("%b %-d, %Y") %> + <%= t(".on_or_after", resource: rule.resource_type.pluralize, date: rule.effective_date.strftime("%b %-d, %Y")) %> <% end %>

@@ -67,11 +67,11 @@ <%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %> <% end %> <%= render DS::Menu.new do |menu| %> - <% menu.with_item(variant: "link", text: "Edit", href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %> - <% menu.with_item(variant: "link", text: "Re-apply rule", href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %> + <% menu.with_item(variant: "link", text: t(".edit"), href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %> + <% menu.with_item(variant: "link", text: t(".re_apply_rule"), href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %> <% menu.with_item( variant: "button", - text: "Delete", + text: t(".delete"), href: rule_path(rule), icon: "trash-2", method: :delete, diff --git a/app/views/rules/confirm.html.erb b/app/views/rules/confirm.html.erb index cf505c8f9..13947e59f 100644 --- a/app/views/rules/confirm.html.erb +++ b/app/views/rules/confirm.html.erb @@ -1,18 +1,16 @@ <%= render DS::Dialog.new(reload_on_close: params[:reload_on_close].present?) do |dialog| %> <% title = if @rule.name.present? - "Confirm changes to \"#{@rule.name}\"" + t(".title_with_name", name: @rule.name) else - "Confirm changes" + t(".title") end %> <% dialog.with_header(title: title) %> <% dialog.with_body do %>

- You are about to apply this rule to - <%= @rule.affected_resource_count %> <%= @rule.resource_type.pluralize %> - that meet the specified rule criteria. Please confirm if you wish to proceed with this change. + <%= t(".apply_notice_html", count: @rule.affected_resource_count, resource: @rule.resource_type.pluralize) %>

<% if @rule.actions.any? { |a| a.action_type == "auto_categorize" } %> @@ -21,25 +19,24 @@
<%= icon "info", class: "w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" %>
-

AI Cost Estimation

+

<%= t(".ai_cost_title") %>

<% if @estimated_cost.present? %>

- This will use AI to categorize <%= affected_count %> transaction<%= "s" if affected_count != 1 %>. - Estimated cost: ~$<%= sprintf("%.4f", @estimated_cost) %> + <%= t(".ai_cost_with_estimate_html", count: affected_count, cost: sprintf("%.4f", @estimated_cost)) %>

<% else %>

- This will use AI to categorize <%= affected_count %> transaction<%= "s" if affected_count != 1 %>. + <%= t(".ai_cost_no_estimate_html", count: affected_count) %> <% if @selected_model.present? %> - Cost estimation unavailable for model "<%= @selected_model %>". + <%= t(".cost_unavailable_model", model: @selected_model) %> <% else %> - Cost estimation unavailable (no LLM provider configured). + <%= t(".cost_unavailable_no_provider") %> <% end %> - You may incur costs, please check with the model provider for the most up-to-date prices. + <%= t(".cost_warning") %>

<% end %>

- <%= link_to "View usage history", settings_llm_usage_path, class: "underline hover:text-blue-800" %> + <%= link_to t(".view_usage_history"), settings_llm_usage_path, class: "underline hover:text-blue-800" %>

@@ -47,7 +44,7 @@ <% end %> <%= render DS::Button.new( - text: "Confirm changes", + text: t(".confirm_changes"), href: apply_rule_path(@rule), method: :post, full_width: true, diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 00d0dfb94..05c5361dd 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -1,4 +1,4 @@ -<%= content_for :page_title, "Rules" %> +<%= content_for :page_title, t(".page_title") %> <%= content_for :page_actions do %> <% if @rules.any? %> <%= render DS::Menu.new do |menu| %> @@ -15,7 +15,7 @@ )) %> <% menu.with_item( variant: "button", - text: "Delete all rules", + text: t(".delete_all_rules"), href: destroy_all_rules_path, icon: "trash-2", method: :delete, @@ -30,7 +30,7 @@ ) %> <% end %> <%= render DS::Link.new( - text: "New rule", + text: t(".new_rule"), variant: "primary", href: new_rule_path(resource_type: "transaction"), icon: "plus", @@ -42,7 +42,7 @@
<%= icon("circle-alert", size: "sm") %>

- AI-enabled rule actions will cost money. Be sure to filter as narrowly as possible to avoid unnecessary costs. + <%= t(".ai_cost_warning") %>

<% end %> @@ -51,15 +51,15 @@
-

Rules

+

<%= t(".rules_heading") %>

·

<%= @rules.count %>

- Sort by: + <%= t(".sort_by") %> <%= form_with url: rules_path, method: :get, local: true, class: "flex items-center", data: { controller: "auto-submit-form" } do |form| %> <%= form.select :sort_by, - options_for_select([["Name", "name"], ["Updated At", "updated_at"]], @sort_by), + options_for_select([[t(".sort_name"), "name"], [t(".sort_updated_at"), "updated_at"]], @sort_by), {}, class: "min-w-[120px] bg-transparent rounded border-none cursor-pointer text-primary uppercase text-xs w-auto", data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %> @@ -70,7 +70,7 @@ variant: "icon", icon: "arrow-up-down", size: :sm, - title: "Toggle sort direction" + title: t(".toggle_sort_direction") ) %>
@@ -83,11 +83,11 @@ <% else %>
-

No rules yet

-

Set up rules to perform actions to your transactions and other data on every account sync.

+

<%= t(".no_rules_title") %>

+

<%= t(".no_rules_description") %>

<%= render DS::Link.new( - text: "New rule", + text: t(".new_rule"), variant: "primary", href: new_rule_path(resource_type: "transaction"), icon: "plus", diff --git a/app/views/sessions/mobile_sso_start.html.erb b/app/views/sessions/mobile_sso_start.html.erb index 74fa9ebfd..123aca3a7 100644 --- a/app/views/sessions/mobile_sso_start.html.erb +++ b/app/views/sessions/mobile_sso_start.html.erb @@ -4,5 +4,5 @@ - + diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index 19bd4a241..f72cbc196 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -28,13 +28,13 @@ nav_sections = [ header: t(".advanced_section_title"), items: [ { label: t(".ai_prompts_label"), path: settings_ai_prompts_path, icon: "bot" }, - { label: "LLM Usage", path: settings_llm_usage_path, icon: "activity" }, + { label: t(".llm_usage_label"), path: settings_llm_usage_path, icon: "activity" }, { label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" }, { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, { label: t(".imports_label"), path: imports_path, icon: "download" }, { label: t(".exports_label"), path: family_exports_path, icon: "upload" }, - { label: "SSO Providers", path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? }, - { label: "Users", path: admin_users_path, icon: "users", if: Current.user&.super_admin? } + { label: t(".sso_providers_label"), path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? }, + { label: t(".users_label"), path: admin_users_path, icon: "users", if: Current.user&.super_admin? } ] } : nil ), diff --git a/app/views/settings/api_keys/created.html.erb b/app/views/settings/api_keys/created.html.erb index d37189000..d0c4e7bca 100644 --- a/app/views/settings/api_keys/created.html.erb +++ b/app/views/settings/api_keys/created.html.erb @@ -1,6 +1,6 @@ -<%= content_for :page_title, "API Key Created" %> +<%= content_for :page_title, t(".page_title") %> -<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %> +<%= settings_section title: t(".success_title"), subtitle: t(".success_description") do %>
@@ -11,21 +11,21 @@ variant: :success ) %>
-

API Key Created Successfully!

-

Your new API key "<%= @api_key.name %>" has been created and is ready to use.

+

<%= t(".success_title") %>

+

<%= t(".key_ready", name: @api_key.name) %>

-

Your API Key

-

Copy and store this key securely. You'll need it to authenticate your API requests.

+

<%= t(".your_api_key") %>

+

<%= t(".copy_store_securely") %>

<%= @api_key.plain_key %> <%= render DS::Button.new( - text: "Copy API Key", + text: t(".copy_key"), variant: "ghost", icon: "copy", data: { action: "clipboard#copy" } @@ -35,42 +35,41 @@
-

Key Details

+

<%= t(".key_details_title") %>

- Name: + <%= t(".key_name_label") %> <%= @api_key.name %>
- Permissions: + <%= t(".permissions_label") %> <%= @api_key.scopes.map { |scope| case scope - when "read_accounts" then "View Accounts" - when "read_transactions" then "View Transactions" - when "read_balances" then "View Balances" - when "write_transactions" then "Create Transactions" + when "read_accounts" then t("settings.api_keys_controller.scope_descriptions.read_accounts") + when "read_transactions" then t("settings.api_keys_controller.scope_descriptions.read_transactions") + when "read_balances" then t("settings.api_keys_controller.scope_descriptions.read_balances") + when "write_transactions" then t("settings.api_keys_controller.scope_descriptions.write_transactions") else scope.humanize end }.join(", ") %>
- Created: + <%= t(".created_label") %> <%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %>
- <%= render DS::Alert.new(title: "Important Security Note", variant: :warning) do %> + <%= render DS::Alert.new(title: t(".security_note_title"), variant: :warning) do %>

- This is the only time your API key will be displayed. Make sure to copy it now and store it securely. - If you lose this key, you'll need to generate a new one. + <%= t(".security_note_body") %>

<% end %>
-

How to use your API key

+

<%= t(".usage_instructions_title") %>

<%= t("settings.api_keys.show.current_api_key.usage_instructions", product_name: product_name) %>

curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts @@ -79,7 +78,7 @@
<%= render DS::Link.new( - text: "Continue to API Key Settings", + text: t(".continue"), href: settings_api_key_path, variant: "primary" ) %> diff --git a/app/views/settings/api_keys/created.turbo_stream.erb b/app/views/settings/api_keys/created.turbo_stream.erb index dec5baeb9..f187a6d31 100644 --- a/app/views/settings/api_keys/created.turbo_stream.erb +++ b/app/views/settings/api_keys/created.turbo_stream.erb @@ -2,10 +2,10 @@

- API Key Created + <%= t("settings.api_keys.created.page_title") %>

- <%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %> + <%= settings_section title: t("settings.api_keys.created.success_title"), subtitle: t("settings.api_keys.created.success_description") do %>
@@ -16,21 +16,21 @@ variant: :success ) %>
-

API Key Created Successfully!

-

Your new API key "<%= @api_key.name %>" has been created and is ready to use.

+

<%= t("settings.api_keys.created.success_title") %>

+

<%= t("settings.api_keys.created.key_ready", name: @api_key.name) %>

-

Your API Key

-

Copy and store this key securely. You'll need it to authenticate your API requests.

+

<%= t("settings.api_keys.created.your_api_key") %>

+

<%= t("settings.api_keys.created.copy_store_securely") %>

<%= @api_key.plain_key %> <%= render DS::Button.new( - text: "Copy API Key", + text: t("settings.api_keys.created.copy_key"), variant: "ghost", icon: "copy", data: { action: "clipboard#copy" } @@ -40,43 +40,42 @@
-

Key Details

+

<%= t("settings.api_keys.created.key_details_title") %>

- Name: + <%= t("settings.api_keys.created.key_name_label") %> <%= @api_key.name %>
- Permissions: + <%= t("settings.api_keys.created.permissions_label") %> <%= @api_key.scopes.map { |scope| case scope - when "read_accounts" then "View Accounts" - when "read_transactions" then "View Transactions" - when "read_balances" then "View Balances" - when "write_transactions" then "Create Transactions" + when "read_accounts" then t("settings.api_keys_controller.scope_descriptions.read_accounts") + when "read_transactions" then t("settings.api_keys_controller.scope_descriptions.read_transactions") + when "read_balances" then t("settings.api_keys_controller.scope_descriptions.read_balances") + when "write_transactions" then t("settings.api_keys_controller.scope_descriptions.write_transactions") else scope.humanize end }.join(", ") %>
- Created: + <%= t("settings.api_keys.created.created_label") %> <%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %>
- <%= render DS::Alert.new(title: "Important Security Note", variant: :warning) do %> + <%= render DS::Alert.new(title: t("settings.api_keys.created.security_note_title"), variant: :warning) do %>

- This is the only time your API key will be displayed. Make sure to copy it now and store it securely. - If you lose this key, you'll need to generate a new one. + <%= t("settings.api_keys.created.security_note_body") %>

<% end %>
-

How to use your API key

-

Include your API key in the X-Api-Key header when making requests:

+

<%= t("settings.api_keys.created.usage_instructions_title") %>

+

<%= t("settings.api_keys.show.current_api_key.usage_instructions", product_name: product_name) %>

curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
@@ -84,7 +83,7 @@
<%= render DS::Link.new( - text: "Continue to API Key Settings", + text: t("settings.api_keys.created.continue"), href: settings_api_key_path, variant: "primary" ) %> diff --git a/app/views/settings/api_keys/new.html.erb b/app/views/settings/api_keys/new.html.erb index 34def86b0..d37a24f70 100644 --- a/app/views/settings/api_keys/new.html.erb +++ b/app/views/settings/api_keys/new.html.erb @@ -1,20 +1,20 @@ -<%= content_for :page_title, "Create New API Key" %> +<%= content_for :page_title, t(".create_new_api_key") %> -<%= settings_section title: nil, subtitle: "Generate a new API key to access your Sure data programmatically." do %> +<%= settings_section title: nil, subtitle: t(".subtitle") do %> <%= styled_form_with model: @api_key, url: settings_api_key_path, class: "space-y-4" do |form| %> <%= form.text_field :name, - placeholder: "e.g., My Budget App, Portfolio Tracker", - label: "API Key Name", - help_text: "Choose a descriptive name to help you identify this key later." %> + placeholder: t(".name_placeholder"), + label: t(".name_label"), + help_text: t(".name_help_text") %>
- <%= form.label :scopes, "Permissions", class: "block text-sm font-medium text-primary mb-2" %> -

Select the permissions this API key should have:

+ <%= form.label :scopes, t(".permissions_label"), class: "block text-sm font-medium text-primary mb-2" %> +

<%= t(".permissions_help") %>

<% [ - ["read", "Read Only", "View your accounts, transactions, and balances"], - ["read_write", "Read/Write", "View your data and create new transactions"] + ["read", t(".scope_read_only"), t(".scope_read_only_description")], + ["read_write", t(".scope_read_write"), t(".scope_read_write_description")] ].each do |value, label, description| %>
- <%= render DS::Alert.new(title: "Security Warning", variant: :warning) do %> + <%= render DS::Alert.new(title: t(".security_warning_title"), variant: :warning) do %>

- Your API key will be displayed only once after creation. Make sure to copy and store it securely. - Anyone with access to this key can access your data according to the permissions you select. + <%= t(".security_warning_body") %>

<% end %>
<%= render DS::Link.new( - text: "Cancel", + text: t(".cancel"), href: settings_api_key_path, variant: "ghost" ) %> <%= render DS::Button.new( - text: "Save API Key", + text: t(".save_api_key"), variant: "primary", type: "submit" ) %> diff --git a/app/views/settings/api_keys/show.html.erb b/app/views/settings/api_keys/show.html.erb index b398aaca1..c32e87349 100644 --- a/app/views/settings/api_keys/show.html.erb +++ b/app/views/settings/api_keys/show.html.erb @@ -1,5 +1,5 @@ <% if @newly_created && @plain_key %> - <%= content_for :page_title, "API Key Created Successfully" %> + <%= content_for :page_title, t(".newly_created.page_title") %>
@@ -12,21 +12,21 @@ variant: :success ) %>
-

API Key Created Successfully!

-

Your new API key "<%= @current_api_key.name %>" has been created and is ready to use.

+

<%= t(".newly_created.heading") %>

+

<%= t(".newly_created.key_ready", name: @current_api_key.name) %>

-

Your API Key

-

Copy and store this key securely. You'll need it to authenticate your API requests.

+

<%= t(".newly_created.your_api_key") %>

+

<%= t(".newly_created.copy_store_securely") %>

<%= @current_api_key.plain_key %> <%= render DS::Button.new( - text: "Copy API Key", + text: t(".newly_created.copy_api_key"), variant: "ghost", icon: "copy", data: { action: "clipboard#copy" } @@ -36,7 +36,7 @@
-

How to use your API key

+

<%= t(".newly_created.how_to_use") %>

<%= t(".current_api_key.usage_instructions", product_name: product_name) %>

curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts @@ -45,7 +45,7 @@
<%= render DS::Link.new( - text: "Continue to API Key Settings", + text: t(".newly_created.continue"), href: settings_api_key_path, variant: "primary" ) %> @@ -53,10 +53,10 @@
<% elsif @current_api_key %> - <%= content_for :page_title, "Your API Key" %> + <%= content_for :page_title, t(".current_api_key.title") %> <%= content_for :page_actions do %> <%= render DS::Link.new( - text: "Create New Key", + text: t(".current_api_key.regenerate_key"), href: new_settings_api_key_path(regenerate: true), variant: "secondary" ) %> @@ -75,30 +75,30 @@

<%= @current_api_key.name %>

- Created <%= time_ago_in_words(@current_api_key.created_at) %> ago + <%= t(".current_api_key.created_ago", time: time_ago_in_words(@current_api_key.created_at)) %> <% if @current_api_key.last_used_at %> - • Last used <%= time_ago_in_words(@current_api_key.last_used_at) %> ago + • <%= t(".current_api_key.last_used_ago", time: time_ago_in_words(@current_api_key.last_used_at)) %> <% else %> - • Never used + • <%= t(".current_api_key.never_used") %> <% end %>

-

Active

+

<%= t(".current_api_key.active") %>

-

Permissions

+

<%= t(".current_api_key.permissions") %>

<% @current_api_key.scopes.each do |scope| %> <%= icon("shield-check", class: "w-3 h-3") %> <%= case scope - when "read" then "Read Only" - when "read_write" then "Read/Write" + when "read" then t(".current_api_key.scope_read_only") + when "read_write" then t(".current_api_key.scope_read_write") else scope.humanize end %> @@ -107,14 +107,14 @@
-

Your API Key

-

Copy and store this key securely. You'll need it to authenticate your API requests.

+

<%= t(".current_api_key.title") %>

+

<%= t(".current_api_key.copy_store_securely") %>

<%= @current_api_key.plain_key %> <%= render DS::Button.new( - text: "Copy API Key", + text: t(".current_api_key.copy_api_key"), variant: "ghost", icon: "copy", data: { action: "clipboard#copy" } @@ -124,7 +124,7 @@
-

How to use your API key

+

<%= t(".current_api_key.usage_instructions_title") %>

<%= t(".current_api_key.usage_instructions", product_name: product_name) %>

curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts @@ -133,12 +133,12 @@
<%= render DS::Button.new( - text: "Revoke Key", + text: t(".current_api_key.revoke_key"), href: settings_api_key_path, method: :delete, variant: "destructive", data: { - turbo_confirm: "Are you sure you want to revoke this API key?" + turbo_confirm: t(".current_api_key.revoke_confirmation") } ) %>
diff --git a/app/views/settings/hostings/_brand_fetch_settings.html.erb b/app/views/settings/hostings/_brand_fetch_settings.html.erb index ea83248e4..03c52701f 100644 --- a/app/views/settings/hostings/_brand_fetch_settings.html.erb +++ b/app/views/settings/hostings/_brand_fetch_settings.html.erb @@ -2,21 +2,21 @@

<%= t(".title") %>

<% if ENV["BRAND_FETCH_CLIENT_ID"].present? %> -

You have successfully configured your Brand Fetch Client ID through the BRAND_FETCH_CLIENT_ID environment variable.

+

<%= t(".env_configured_message") %>

<% else %>
<%= t(".description") %>
- (show details) + <%= t(".show_details") %>
  1. - Visit brandfetch.com and create a free Brand Fetch Developer account. + <%= t(".setup_step_1_html") %>
  2. - Go to the Logo API page. + <%= t(".setup_step_2_html") %>
  3. - Tap the eye icon under the "Your Client ID" section to reveal your Client ID and paste it below. + <%= t(".setup_step_3") %>
diff --git a/app/views/settings/llm_usages/show.html.erb b/app/views/settings/llm_usages/show.html.erb index 7abcd4f5e..dfb11ead1 100644 --- a/app/views/settings/llm_usages/show.html.erb +++ b/app/views/settings/llm_usages/show.html.erb @@ -1,22 +1,22 @@ -<%= content_for :page_title, "LLM Usage & Costs" %> +<%= content_for :page_title, t(".page_title") %>
-

Track your AI usage and estimated costs

+

<%= t(".subtitle") %>

<%= form_with url: settings_llm_usage_path, method: :get, class: "flex gap-4 items-end flex-wrap" do |f| %>
- <%= f.label :start_date, "Start Date", class: "block text-sm font-medium text-primary mb-1" %> + <%= f.label :start_date, t(".start_date"), class: "block text-sm font-medium text-primary mb-1" %> <%= f.date_field :start_date, value: @start_date, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %>
- <%= f.label :end_date, "End Date", class: "block text-sm font-medium text-primary mb-1" %> + <%= f.label :end_date, t(".end_date"), class: "block text-sm font-medium text-primary mb-1" %> <%= f.date_field :end_date, value: @end_date, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %>
- <%= render DS::Button.new(variant: :primary, size: :md, type: "submit", text: "Filter", class: "md:w-auto w-full justify-center") %> + <%= render DS::Button.new(variant: :primary, size: :md, type: "submit", text: t(".filter"), class: "md:w-auto w-full justify-center") %> <% end %>
@@ -25,7 +25,7 @@
<%= icon "activity", class: "w-5 h-5 text-secondary" %> -

Total Requests

+

<%= t(".total_requests") %>

<%= number_with_delimiter(@statistics[:total_requests]) %>

@@ -33,19 +33,19 @@
<%= icon "hash", class: "w-5 h-5 text-secondary" %> -

Total Tokens

+

<%= t(".total_tokens") %>

<%= number_with_delimiter(@statistics[:total_tokens]) %>

- <%= number_with_delimiter(@statistics[:total_prompt_tokens]) %> prompt / - <%= number_with_delimiter(@statistics[:total_completion_tokens]) %> completion + <%= number_with_delimiter(@statistics[:total_prompt_tokens]) %> <%= t(".prompt") %> / + <%= number_with_delimiter(@statistics[:total_completion_tokens]) %> <%= t(".completion") %>

<%= icon "dollar-sign", class: "w-5 h-5 text-secondary" %> -

Total Cost

+

<%= t(".total_cost") %>

$<%= sprintf("%.2f", @statistics[:total_cost]) %>

@@ -53,15 +53,14 @@
<%= icon "trending-up", class: "w-5 h-5 text-secondary" %> -

Avg Cost/Request

+

<%= t(".avg_cost_per_request") %>

$<%= sprintf("%.4f", @statistics[:avg_cost]) %>

<% if @statistics[:requests_with_cost] < @statistics[:total_requests] %>

- Based on <%= number_with_delimiter(@statistics[:requests_with_cost]) %> of - <%= number_with_delimiter(@statistics[:total_requests]) %> requests with cost data + <%= t(".based_on_requests", with_cost: number_with_delimiter(@statistics[:requests_with_cost]), total: number_with_delimiter(@statistics[:total_requests])) %>

<% end %>
@@ -70,7 +69,7 @@ <% if @statistics[:by_operation].any? %>
-

Cost by Operation

+

<%= t(".cost_by_operation") %>

<% @statistics[:by_operation].each do |operation, cost| %> @@ -87,7 +86,7 @@ <% if @statistics[:by_model].any? %>
-

Cost by Model

+

<%= t(".cost_by_model") %>

<% @statistics[:by_model].each do |model, cost| %> @@ -103,17 +102,17 @@
-

Recent Usage

+

<%= t(".recent_usage") %>

<% if @llm_usages.any? %> - - - - - + + + + + @@ -133,7 +132,7 @@
<%= icon "alert-circle", class: "w-4 h-4 text-red-600 theme-dark:text-red-400" %> - Failed + <%= t(".failed") %>
DateOperationModelTokensCost<%= t(".col_date") %><%= t(".col_operation") %><%= t(".col_model") %><%= t(".col_tokens") %><%= t(".col_cost") %>
<% else %>
-

No usage data found for the selected period

+

<%= t(".no_usage_data") %>

<% end %>
@@ -171,11 +170,9 @@
<%= icon "info", class: "w-5 h-5 text-blue-600 mt-0.5" %>
-

About Cost Estimates

+

<%= t(".cost_estimates_title") %>

- Costs are estimated based on OpenAI's pricing as of 2025. Actual costs may vary. - Pricing is per 1 million tokens and varies by model. - Custom or self-hosted models will show "N/A" and are not included in cost totals. + <%= t(".cost_estimates_description") %>

diff --git a/app/views/settings/payments/show.html.erb b/app/views/settings/payments/show.html.erb index ac0184cea..0768440cd 100644 --- a/app/views/settings/payments/show.html.erb +++ b/app/views/settings/payments/show.html.erb @@ -13,7 +13,7 @@
<% if @family.has_active_subscription? %>

- Currently on the <%= @family.subscription.name %>.
+ <%= t(".currently_on_plan") %> <%= @family.subscription.name %>.
<% if @family.next_payment_date %> <% if @family.subscription_pending_cancellation? %> @@ -25,21 +25,21 @@

<% elsif @family.trialing? %>

- Currently using the open demo of <%= product_name %>
+ <%= t(".trialing", product_name: product_name) %>
- (Data will be deleted in <%= @family.days_left_in_trial %> days) + (<%= t(".trial_days_left", count: @family.days_left_in_trial) %>)

<% else %> -

You are currently not contributing

-

Contributions to <%= product_name %> will show here.

+

<%= t(".not_contributing_prefix") %> <%= t(".not_contributing_emphasis") %>

+

<%= t(".contributions_note", product_name: product_name) %>

<% end %>
<% if @family.has_active_subscription? %> <%= render DS::Link.new( - text: "Manage", + text: t(".manage"), icon: "external-link", variant: "primary", icon_position: "right", @@ -48,7 +48,7 @@ ) %> <% else %> <%= render DS::Link.new( - text: "Choose level", + text: t(".choose_level"), variant: "primary", icon: "plus", icon_position: "right", @@ -59,7 +59,7 @@
<%= image_tag "stripe-logo.svg", class: "w-5 h-5 shrink-0" %> -

Payment via Stripe

+

<%= t(".payment_via_stripe") %>

<% end %> diff --git a/app/views/settings/providers/_enable_banking_panel.html.erb b/app/views/settings/providers/_enable_banking_panel.html.erb index 45344ea55..b73009f96 100644 --- a/app/views/settings/providers/_enable_banking_panel.html.erb +++ b/app/views/settings/providers/_enable_banking_panel.html.erb @@ -65,32 +65,32 @@ ["Sweden (SE)", "SE"], ["United Kingdom (GB)", "GB"] ], enable_banking_item.country_code), - { label: true, include_blank: "Select country..." }, - { label: "Country", class: "form-field__input" } %> + { label: true, include_blank: t("settings.providers.enable_banking_panel.select_country") }, + { label: t("settings.providers.enable_banking_panel.country_label"), class: "form-field__input" } %> <% if has_authenticated_connections && !is_new_record %> <%= render DS::Alert.new( variant: :warning, - title: "Configuration locked", - message: "Disconnect all linked banks before changing these credentials." + title: t("settings.providers.enable_banking_panel.config_locked_title"), + message: t("settings.providers.enable_banking_panel.config_locked_message") ) %> <% end %> <%= form.text_field :application_id, - label: "Application ID", - placeholder: is_new_record ? "Enter application ID" : "Enter new ID to update", + label: t("settings.providers.enable_banking_panel.application_id_label"), + placeholder: is_new_record ? t("settings.providers.enable_banking_panel.application_id_placeholder_new") : t("settings.providers.enable_banking_panel.application_id_placeholder_update"), value: enable_banking_item.application_id, disabled: has_authenticated_connections && !is_new_record %> <%= form.text_area :client_certificate, - label: "Client Certificate (with Private Key)", + label: t("settings.providers.enable_banking_panel.client_certificate_label"), placeholder: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", rows: 6, class: "form-field__input font-mono text-xs", disabled: has_authenticated_connections && !is_new_record %>
- <%= form.submit is_new_record ? "Save and connect" : "Update connection", + <%= form.submit is_new_record ? t("settings.providers.enable_banking_panel.save_and_connect") : t("settings.providers.enable_banking_panel.update_connection"), class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
<% end %> @@ -122,22 +122,22 @@ <% elsif item.session_valid? %>
-

<%= item.aspsp_name || "Connected Bank" %>

+

<%= item.aspsp_name || t("settings.providers.enable_banking_panel.connected_bank") %>

- Session expires: <%= item.session_expires_at&.strftime("%b %d, %Y") || "Unknown" %> + <%= t("settings.providers.enable_banking_panel.session_expires", date: item.session_expires_at&.strftime("%b %d, %Y") || t("settings.providers.enable_banking_panel.unknown")) %>

<% elsif item.session_expired? %>
-

<%= item.aspsp_name || "Connection" %>

-

Session expired - reconnect

+

<%= item.aspsp_name || t("settings.providers.enable_banking_panel.connection") %>

+

<%= t("settings.providers.enable_banking_panel.session_expired_reconnect") %>

<% else %>
-

Configured

-

Ready to link accounts

+

<%= t("settings.providers.enable_banking_panel.configured") %>

+

<%= t("settings.providers.enable_banking_panel.ready_to_link") %>

<% end %>
@@ -148,28 +148,28 @@ method: :post, class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-primary bg-container border border-primary hover:bg-surface-inset transition-colors", data: { turbo: false } do %> - Sync + <%= t("settings.providers.enable_banking_panel.sync") %> <% end %> <% elsif item.session_expired? %> <%= button_to reauthorize_enable_banking_item_path(item), method: :post, class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-warning hover:opacity-90 transition-colors", data: { turbo: false } do %> - Reconnect + <%= t("settings.providers.enable_banking_panel.reconnect") %> <% end %> <% else %> <%= link_to select_bank_enable_banking_item_path(item), class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-inverse button-bg-primary hover:button-bg-primary-hover transition-colors", data: { turbo_frame: "modal" } do %> - Connect bank + <%= t("settings.providers.enable_banking_panel.connect_bank") %> <% end %> <% end %> <%= button_to enable_banking_item_path(item), method: :delete, class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors", - data: { turbo_confirm: "Are you sure you want to remove this connection?" } do %> - Remove + data: { turbo_confirm: t("settings.providers.enable_banking_panel.remove_confirm") } do %> + <%= t("settings.providers.enable_banking_panel.remove") %> <% end %>
@@ -183,7 +183,7 @@ class: "inline-flex items-center gap-2 justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover transition-colors", data: { turbo_frame: "modal" } do %> <%= icon "plus", size: "sm" %> - Add Connection + <%= t("settings.providers.enable_banking_panel.add_connection") %> <% end %>
<% end %> diff --git a/app/views/settings/providers/_lunchflow_panel.html.erb b/app/views/settings/providers/_lunchflow_panel.html.erb index e4bccf2ba..9b128f8dd 100644 --- a/app/views/settings/providers/_lunchflow_panel.html.erb +++ b/app/views/settings/providers/_lunchflow_panel.html.erb @@ -27,17 +27,17 @@ data: { turbo: true }, class: "space-y-3" do |form| %> <%= form.text_field :api_key, - label: "API Key", - placeholder: is_new_record ? "Paste API key here" : "Enter new API key to update", + label: t("settings.providers.lunchflow_panel.api_key_label"), + placeholder: is_new_record ? t("settings.providers.lunchflow_panel.api_key_placeholder_new") : t("settings.providers.lunchflow_panel.api_key_placeholder_update"), type: :password %> <%= form.text_field :base_url, - label: "Base URL (Optional)", - placeholder: "https://lunchflow.app/api/v1 (default)", + label: t("settings.providers.lunchflow_panel.base_url_label"), + placeholder: t("settings.providers.lunchflow_panel.base_url_placeholder"), value: lunchflow_item.base_url %>
- <%= form.submit is_new_record ? "Save and connect" : "Update connection", + <%= form.submit is_new_record ? t("settings.providers.lunchflow_panel.save_and_connect") : t("settings.providers.lunchflow_panel.update_connection"), class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
<% end %> diff --git a/app/views/settings/providers/_simplefin_panel.html.erb b/app/views/settings/providers/_simplefin_panel.html.erb index a20b6ac4b..b169c5cd9 100644 --- a/app/views/settings/providers/_simplefin_panel.html.erb +++ b/app/views/settings/providers/_simplefin_panel.html.erb @@ -20,12 +20,12 @@ data: { turbo: true }, class: "space-y-3" do |form| %> <%= form.text_field :setup_token, - label: "Setup Token", - placeholder: "Paste SimpleFIN setup token", + label: t("settings.providers.simplefin_panel.setup_token_label"), + placeholder: t("settings.providers.simplefin_panel.setup_token_placeholder"), type: :password %>
- <%= form.submit "Save and connect", + <%= form.submit t("settings.providers.simplefin_panel.save_and_connect"), class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
<% end %> diff --git a/app/views/simplefin_items/edit.html.erb b/app/views/simplefin_items/edit.html.erb index f0d3e0811..51f26f39d 100644 --- a/app/views/simplefin_items/edit.html.erb +++ b/app/views/simplefin_items/edit.html.erb @@ -1,11 +1,11 @@ -<% content_for :title, "Update SimpleFin Connection" %> +<% content_for :title, t(".title") %> <%= turbo_frame_tag "modal" do %> <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Update SimpleFin Connection") do %> + <% dialog.with_header(title: t(".title")) do %>
<%= icon "building-2", class: "text-primary" %> - Get a new setup token to reconnect your SimpleFin account + <%= t(".header_subtitle") %>
<% end %> @@ -16,12 +16,12 @@ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>

- Your SimpleFIN connection needs to be updated: + <%= t(".connection_needs_update") %>

    -
  1. Visit SimpleFIN Bridge to create a new setup token
  2. -
  3. Copy the token and paste it below
  4. -
  5. Click "Update" to restore access
  6. +
  7. <%= t(".step_1_html") %>
  8. +
  9. <%= t(".step_2") %>
  10. +
  11. <%= t(".step_3") %>
@@ -46,14 +46,14 @@
<%= render DS::Button.new( - text: "Update", + text: t(".update"), variant: "primary", icon: "refresh-cw", type: "submit", class: "flex-1" ) %> <%= render DS::Link.new( - text: "Cancel", + text: t(".cancel"), variant: "secondary", href: accounts_path ) %> diff --git a/app/views/simplefin_items/setup_accounts.html.erb b/app/views/simplefin_items/setup_accounts.html.erb index 81070c2e1..cf8441da3 100644 --- a/app/views/simplefin_items/setup_accounts.html.erb +++ b/app/views/simplefin_items/setup_accounts.html.erb @@ -1,10 +1,10 @@ -<% content_for :title, "Set Up SimpleFin Accounts" %> +<% content_for :title, t(".title") %> <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Set Up Your SimpleFin Accounts") do %> + <% dialog.with_header(title: t(".title")) do %>
<%= icon "building-2", class: "text-primary" %> - Choose the correct account types for your imported accounts + <%= t(".header_subtitle") %>
<% end %> @@ -15,7 +15,7 @@ data: { controller: "loading-button", action: "submit->loading-button#showLoading", - loading_button_loading_text_value: "Creating Accounts...", + loading_button_loading_text_value: t(".creating_accounts"), turbo_frame: "_top" }, class: "space-y-6" do |form| %> @@ -26,14 +26,14 @@ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>

- Choose the correct account type for each SimpleFin account: + <%= t(".choose_account_type") %>

    -
  • Checking or Savings - Regular bank accounts
  • -
  • Credit Card - Credit card accounts
  • -
  • Investment - Brokerage, 401(k), IRA accounts
  • -
  • Loan or Mortgage - Debt accounts
  • -
  • Other Asset - Everything else
  • +
  • <%= t(".account_type_checking_savings") %> - <%= t(".account_type_checking_savings_desc") %>
  • +
  • <%= t(".account_type_credit_card") %> - <%= t(".account_type_credit_card_desc") %>
  • +
  • <%= t(".account_type_investment") %> - <%= t(".account_type_investment_desc") %>
  • +
  • <%= t(".account_type_loan") %> - <%= t(".account_type_loan_desc") %>
  • +
  • <%= t(".account_type_other_asset") %> - <%= t(".account_type_other_asset_desc") %>
@@ -45,12 +45,10 @@ <%= icon "clock", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>

- Transaction History: + <%= t(".transaction_history_title") %>

- SimpleFin typically provides 60-90 days of transaction history, depending on your bank. - After initial setup, new transactions will sync automatically going forward. - Historical data availability varies by institution and account type. + <%= t(".transaction_history_description_html") %>

@@ -100,7 +98,7 @@
- <%= label_tag "account_types[#{simplefin_account.id}]", "Account Type:", + <%= label_tag "account_types[#{simplefin_account.id}]", t(".account_type_label"), class: "block text-sm font-medium text-primary mb-2" %> <%= select_tag "account_types[#{simplefin_account.id}]", options_for_select(@account_type_options, selected_type), @@ -146,7 +144,7 @@
<%= render DS::Button.new( - text: "Create Accounts", + text: t(".create_accounts"), variant: "primary", icon: "plus", type: "submit", @@ -154,7 +152,7 @@ data: { loading_button_target: "button" } ) %> <%= render DS::Link.new( - text: "Cancel", + text: t(".cancel"), variant: "secondary", href: accounts_path ) %> diff --git a/app/views/subscriptions/upgrade.html.erb b/app/views/subscriptions/upgrade.html.erb index 0c600cf99..fd7d49936 100644 --- a/app/views/subscriptions/upgrade.html.erb +++ b/app/views/subscriptions/upgrade.html.erb @@ -1,17 +1,17 @@