mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge branch 'main' into move_pr_check
This commit is contained in:
@@ -191,22 +191,19 @@ jobs:
|
||||
# untrusted tree gets built below - never leave credentials in .git/config
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-deploy-pr-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -34,23 +34,20 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK ${{ matrix.jdk-version }}
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Check Java formatting (Spotless)
|
||||
@@ -109,7 +106,7 @@ jobs:
|
||||
STIRLING_FLAVOR: ${{ matrix.flavor }}
|
||||
# Configure the Gradle daemon explicitly; GRADLE_OPTS alone only
|
||||
# configures the Gradle client JVM.
|
||||
GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC'
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC"
|
||||
|
||||
- name: Check Test Reports Exist
|
||||
if: always()
|
||||
|
||||
@@ -15,6 +15,11 @@ name: Enterprise E2E (Playwright)
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
use_shared_cache:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
@@ -56,21 +61,31 @@ jobs:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
if: inputs.use_shared_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Cache Gradle User Home
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Restore cache Gradle
|
||||
if: inputs.use_shared_cache == false
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-playwright-e2e-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
+45
-14
@@ -74,29 +74,48 @@ jobs:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
|
||||
- name: Calculate Gradle cache key
|
||||
id: gradle-cache-key
|
||||
shell: bash
|
||||
run: |
|
||||
echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Gradle (lookup-only)
|
||||
id: cache-gradle-restore
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: ${{ steps.gradle-cache-key.outputs.key }}
|
||||
lookup-only: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Resolve backend dependencies
|
||||
run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon
|
||||
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
|
||||
run: ./gradlew :stirling-pdf:classes --no-daemon
|
||||
env:
|
||||
STIRLING_FLAVOR: saas
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Save cache Gradle User Home
|
||||
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ steps.gradle-cache-key.outputs.key }}
|
||||
|
||||
build:
|
||||
needs: [files-changed, gradle-cache-prime]
|
||||
permissions:
|
||||
@@ -178,6 +197,8 @@ jobs:
|
||||
contents: read
|
||||
uses: ./.github/workflows/build-enterprise.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
use_shared_cache: true
|
||||
|
||||
check-licence:
|
||||
if: needs.files-changed.outputs.build == 'true'
|
||||
@@ -201,7 +222,14 @@ jobs:
|
||||
|
||||
test-build-docker-images:
|
||||
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
|
||||
needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime]
|
||||
needs:
|
||||
[
|
||||
files-changed,
|
||||
build,
|
||||
check-generateOpenApiDocs,
|
||||
check-licence,
|
||||
gradle-cache-prime,
|
||||
]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
@@ -213,7 +241,7 @@ jobs:
|
||||
|
||||
tauri-build:
|
||||
if: needs.files-changed.outputs.tauri == 'true'
|
||||
needs: [files-changed]
|
||||
needs: [files-changed, gradle-cache-prime]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -227,6 +255,7 @@ jobs:
|
||||
with:
|
||||
platform: windows-macos
|
||||
sign: true
|
||||
use_shared_cache: true
|
||||
|
||||
ai-engine:
|
||||
if: needs.files-changed.outputs.engine == 'true'
|
||||
@@ -248,6 +277,8 @@ jobs:
|
||||
contents: read
|
||||
uses: ./.github/workflows/check-generated-models.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
use_shared_cache: true
|
||||
|
||||
pre-commit:
|
||||
needs: [files-changed]
|
||||
|
||||
@@ -9,6 +9,11 @@ name: Check generated models
|
||||
# post-merge safety net.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
use_shared_cache:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -38,22 +43,29 @@ jobs:
|
||||
engine/uv.lock
|
||||
cache-suffix: generated-models
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
- name: Restore cache Gradle User Home
|
||||
if: inputs.use_shared_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Restore cache Gradle
|
||||
if: inputs.use_shared_cache == false
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-generated-models-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
||||
@@ -21,23 +21,20 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Check licenses for compatibility
|
||||
|
||||
@@ -22,23 +22,20 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Generate OpenAPI documentation
|
||||
|
||||
@@ -40,23 +40,20 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
|
||||
@@ -25,23 +25,20 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: 25
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# Keep the normal formatting path here so this smoke test exercises the
|
||||
# same Gradle configuration as the backend build.
|
||||
- name: Build Stirling-PDF JAR
|
||||
|
||||
@@ -33,23 +33,20 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# When the PR changes the base image, test.sh builds it locally
|
||||
# (stirling-pdf-base:local) into the daemon image store. A buildx
|
||||
# container builder can't see that store, so skip it here and let
|
||||
|
||||
@@ -21,39 +21,21 @@ jobs:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
# Gradle does not retry 429s, and a cold cache resolving the buildscript
|
||||
# classpath is exactly where Maven Central rate-limits us. Retry it here,
|
||||
# where a failure is cheap, instead of inside the backgrounded bootRun.
|
||||
- name: Prime Gradle dependencies
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if ./gradlew --quiet -PnoSpotless :stirling-pdf:classes; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::Gradle dependency resolution failed (attempt $attempt of 3)"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
echo "::error::Gradle could not resolve dependencies after 3 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -287,22 +287,19 @@ jobs:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-license-report-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -52,22 +52,19 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -145,22 +142,19 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
@@ -238,6 +232,14 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
# x86_64 JDK is set up first so the aarch64 step below can leave its
|
||||
# JAVA_HOME as the active one. The macOS universal JRE build needs
|
||||
# jmods from both arches; the x64 path is captured into the env
|
||||
@@ -261,17 +263,6 @@ jobs:
|
||||
java-version: "25"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
|
||||
@@ -58,22 +58,19 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-push-docker-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
|
||||
@@ -36,22 +36,19 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-swagger-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
|
||||
@@ -26,6 +26,10 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
use_shared_cache:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
@@ -168,6 +172,24 @@ jobs:
|
||||
# Save the dependency cache even if a later step fails
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
if: inputs.use_shared_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Restore cache Gradle
|
||||
if: inputs.use_shared_cache == false
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-tauri-build-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
@@ -187,17 +209,6 @@ jobs:
|
||||
java-version: "25"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
|
||||
@@ -79,23 +79,20 @@ jobs:
|
||||
docker system prune -af || true
|
||||
echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Build application
|
||||
|
||||
@@ -1879,6 +1879,9 @@ width = "Width"
|
||||
[app]
|
||||
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
|
||||
[appBanner]
|
||||
dismiss = "Dismiss"
|
||||
|
||||
[attachments]
|
||||
convertToPdfA3b = "Convert to PDF/A-3b"
|
||||
convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments"
|
||||
@@ -4827,9 +4830,6 @@ title = "Image to PDF"
|
||||
[imageToPdf]
|
||||
tags = "conversion,img,jpg,picture,photo"
|
||||
|
||||
[infoBanner]
|
||||
dismiss = "Dismiss"
|
||||
|
||||
[invite]
|
||||
acceptError = "Failed to create account"
|
||||
accountFor = "Creating account for"
|
||||
@@ -5057,6 +5057,14 @@ title = "Upload from Mobile"
|
||||
tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide"
|
||||
title = "PDF Multi Tool"
|
||||
|
||||
[navFooter]
|
||||
openEditor = "Open PDF Editor"
|
||||
openProcessor = "Open PDF Processor"
|
||||
|
||||
[navFooter.credits]
|
||||
count = "{{remaining}} of {{total}}"
|
||||
label = "Free credits"
|
||||
|
||||
[oauth.error]
|
||||
message = "Authentication was not successful. You can close this window and try again."
|
||||
title = "Authentication Failed"
|
||||
@@ -5621,8 +5629,8 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man
|
||||
freeTitle = "Unlimited PDF editing"
|
||||
|
||||
[payg.free.hero]
|
||||
barAria = "Free PDFs used"
|
||||
capSuffix = "/ {{limit}} free PDFs"
|
||||
barAria = "Free PDFs remaining"
|
||||
capSuffix = "of {{limit}} free PDFs left"
|
||||
metaCategories = "Automation · AI · API requests"
|
||||
|
||||
[payg.free.member]
|
||||
@@ -6685,12 +6693,12 @@ reachedTitle = "Monthly spend limit reached"
|
||||
title = "Couldn't open Stripe portal"
|
||||
|
||||
[portal.billing.walletMeter]
|
||||
barAria = "Free PDFs used"
|
||||
capSuffix_one = "of {{allowance}} free PDFs used"
|
||||
capSuffix_other = "of {{allowance}} free PDFs used"
|
||||
barAria = "Free PDFs remaining"
|
||||
capSuffix_one = "of {{allowance}} free PDF left"
|
||||
capSuffix_other = "of {{allowance}} free PDFs left"
|
||||
eyebrow = "Processor trial"
|
||||
statusLabel_one = "{{remaining}} left"
|
||||
statusLabel_other = "{{remaining}} left"
|
||||
statusLabel_one = "{{used}} used"
|
||||
statusLabel_other = "{{used}} used"
|
||||
sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
|
||||
title_one = "Process {{allowance}} PDFs free"
|
||||
title_other = "Process {{allowance}} PDFs free"
|
||||
@@ -7665,7 +7673,6 @@ integrations = "Integrations"
|
||||
pipelines = "Pipelines"
|
||||
policies = "Policies"
|
||||
procurement = "Procurement"
|
||||
settings = "Settings"
|
||||
sources = "Sources"
|
||||
usage = "Usage & Billing"
|
||||
users = "Users"
|
||||
|
||||
@@ -642,7 +642,6 @@ const CODE_EXEMPT_PATH = [
|
||||
/mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons|BrandMarks/,
|
||||
/\/onboarding\//,
|
||||
/addStamp|addWatermark|\/tooltips\//,
|
||||
/UpgradeBanner|AdminPlanSection/,
|
||||
// Stories are checked like app code; colour-as-data lines opt out with
|
||||
// `theme-allow-color`.
|
||||
/\.test\.[jt]sx?$|\/types\//,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Group, Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
|
||||
/**
|
||||
@@ -105,7 +105,7 @@ export function TeamInvitationBanner() {
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="mail"
|
||||
message={
|
||||
<Group
|
||||
@@ -120,10 +120,6 @@ export function TeamInvitationBanner() {
|
||||
}
|
||||
show={shouldShow}
|
||||
dismissible={false}
|
||||
background="var(--mantine-color-dark-7)"
|
||||
borderColor="var(--mantine-color-dark-5)"
|
||||
textColor="rgba(255, 255, 255, 0.95)"
|
||||
iconColor="rgba(255, 255, 255, 0.95)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatPeriodDate,
|
||||
MeterBar,
|
||||
meterState,
|
||||
remainingMeter,
|
||||
} from "@app/billing";
|
||||
import "@app/components/shared/config/configSections/Payg.css";
|
||||
import "@app/components/shared/config/configSections/PaygFree.css";
|
||||
@@ -48,7 +49,8 @@ export function useFreeSnapshot(): FreeSnapshot {
|
||||
|
||||
export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
|
||||
const remaining = Math.max(0, snap.billableLimit - snap.billableUsed);
|
||||
const { state, pct } = remainingMeter(remaining, snap.billableLimit);
|
||||
const stateLabel =
|
||||
state === "DEGRADED"
|
||||
? t("payg.free.state.limitReached", "Limit reached")
|
||||
@@ -60,9 +62,9 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
barLabel={t("payg.free.hero.barAria", "Free PDFs used")}
|
||||
figure={snap.billableUsed.toLocaleString()}
|
||||
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
|
||||
barLabel={t("payg.free.hero.barAria", "Free PDFs remaining")}
|
||||
figure={remaining.toLocaleString()}
|
||||
capSuffix={t("payg.free.hero.capSuffix", "of {{limit}} free PDFs left", {
|
||||
limit: snap.billableLimit.toLocaleString(),
|
||||
})}
|
||||
statusLabel={stateLabel}
|
||||
@@ -175,15 +177,15 @@ export function prepaidSnapshotFromWallet(
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepaid capacity meter. The bar fills as the pool is drawn down ({@code used =
|
||||
* total − remaining}), so it WARNs when the pool is running low and DEGRADEs once
|
||||
* exhausted — same bands as the free/cap meters. Prepaid is consumed ahead of the
|
||||
* meter and outside the spend cap, so it reads as its own dimension.
|
||||
* Prepaid capacity meter. The bar shows what is left and drains towards empty,
|
||||
* while the bands still key on what is gone ({@code used = total − remaining}), so
|
||||
* it WARNs when the pool is running low and DEGRADEs once exhausted — same bands
|
||||
* as the free/cap meters. Prepaid is consumed ahead of the meter and outside the
|
||||
* spend cap, so it reads as its own dimension.
|
||||
*/
|
||||
export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
const used = Math.max(0, snap.total - snap.remaining);
|
||||
const { state, pct } = meterState(used, snap.total);
|
||||
const { state, pct } = remainingMeter(snap.remaining, snap.total);
|
||||
const stateLabel =
|
||||
state === "DEGRADED"
|
||||
? t("payg.prepaid.state.exhausted", "Used up")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useWallet } from "@app/hooks/useWallet";
|
||||
import {
|
||||
readCachedCredits,
|
||||
writeCachedCredits,
|
||||
type CachedCredits,
|
||||
} from "@app/services/navFooterCache";
|
||||
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
|
||||
|
||||
/** The wallet reduced to what the footer shows: figures, or null for a payer. */
|
||||
function toCredits(
|
||||
status: string,
|
||||
freeRemaining: number,
|
||||
freeAllowance: number,
|
||||
): CachedCredits {
|
||||
// Free teams only. The grant is a lifetime pool that survives subscribing, so
|
||||
// a paying team would otherwise sit on a permanent "0 of 500" in red while
|
||||
// nothing is wrong. Plan draws the same line — subscribed teams get the
|
||||
// spend-vs-cap meter there, and admins get usage in the processor.
|
||||
if (status === "subscribed") return null;
|
||||
return { remaining: freeRemaining, total: freeAllowance };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud builds read the free grant off the live wallet — the same snapshot the
|
||||
* Plan page's free meter renders, so the sidebar and Plan can't disagree.
|
||||
*
|
||||
* Seeded from the last figures this browser saw, so the row is right at first
|
||||
* paint and stays put while the wallet refetches underneath. Only a browser
|
||||
* that has never loaded a wallet has nothing to show, and that one time the row
|
||||
* animates in. The seed is read once, at first render: later reads would fight
|
||||
* the live value, and the whole point is that the row stops moving.
|
||||
*/
|
||||
export function useFreeCreditsSummary(): NavFooterCredits | null {
|
||||
const { wallet } = useWallet();
|
||||
const [seed] = useState(readCachedCredits);
|
||||
|
||||
const live = wallet
|
||||
? toCredits(wallet.status, wallet.freeRemaining, wallet.freeAllowance)
|
||||
: undefined;
|
||||
|
||||
// useWallet reuses the snapshot reference when nothing changed, so keying on
|
||||
// it writes only on a real change, not on every render.
|
||||
useEffect(() => {
|
||||
if (live !== undefined) writeCachedCredits(live);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wallet]);
|
||||
|
||||
return (live !== undefined ? live : seed) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Cloud editor builds open the settings modal on its Plan section, which is
|
||||
* where the free grant is explained and the Processor plan is switched on.
|
||||
* Routed rather than called directly because the modal is URL-driven here
|
||||
* (`/settings/*`), the same path the admin tour uses to open it.
|
||||
*/
|
||||
export function useOpenPlan(): (() => void) | null {
|
||||
const navigate = useNavigate();
|
||||
return useCallback(() => navigate("/settings/plan"), [navigate]);
|
||||
}
|
||||
@@ -32,6 +32,14 @@
|
||||
* promise see the UI flip exactly once the new state is visible — no
|
||||
* intermediate flash of the old value.
|
||||
*
|
||||
* <h2>Freshness</h2>
|
||||
*
|
||||
* The figures drain as metered work runs, so a mounted consumer re-reads the
|
||||
* wallet every {@link WALLET_POLL_MS} and again whenever the tab regains
|
||||
* visibility. Those refreshes are silent — they leave {@code loading} and
|
||||
* {@code error} alone and only commit fresher data — so consumers that gate on
|
||||
* those flags don't flicker on a background tick.
|
||||
*
|
||||
* <h2>Dev preview fallback</h2>
|
||||
*
|
||||
* When the hook is rendered outside the saas app (e.g. on {@code
|
||||
@@ -178,6 +186,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
|
||||
return prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* How often a mounted consumer re-reads the wallet. Matches the app query
|
||||
* client's staleTime, so the sidebar meter and anything cached elsewhere age
|
||||
* out on the same clock.
|
||||
*/
|
||||
const WALLET_POLL_MS = 30_000;
|
||||
|
||||
export function useWallet(): UseWalletResult {
|
||||
// Resolved once: the dev-preview side-channel when rendered outside the real
|
||||
// app (saas /dev/payg-preview route), else null (every real build + desktop).
|
||||
@@ -201,13 +216,29 @@ export function useWallet(): UseWalletResult {
|
||||
// "the request fired." Cleared when no load is pending.
|
||||
const inFlight = useRef<Promise<void> | null>(null);
|
||||
|
||||
// Set for refreshes the user didn't ask for (the poll below). Silence governs
|
||||
// whether a load may RAISE `loading` / `error`, never whether it may clear
|
||||
// them: consumers gate on both — the limit modals do
|
||||
// `if (loading || !wallet) return null`, and Plan swaps in an error alert —
|
||||
// so a background tick must not blink an open modal out or replace a working
|
||||
// page over a transient failure. Clearing is always the latest request's job,
|
||||
// silent or not; a silent load that skipped the clear would strand `loading`
|
||||
// true after superseding a visible one, which suppresses those modals for the
|
||||
// rest of the session.
|
||||
const silentRefresh = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const reqId = ++latestReqId.current;
|
||||
let cancelled = false;
|
||||
|
||||
const silent = silentRefresh.current;
|
||||
silentRefresh.current = false;
|
||||
|
||||
const promise = (async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
if (devPreview) {
|
||||
const synth = devPreview.buildWallet(devPreview.role());
|
||||
@@ -221,11 +252,22 @@ export function useWallet(): UseWalletResult {
|
||||
const res = await apiClient.get<Wallet>("/api/v1/payg/wallet");
|
||||
if (cancelled || reqId !== latestReqId.current) return;
|
||||
setWallet((prev) => reuseIfEqual(prev, res.data));
|
||||
// Fresh data retires any earlier failure, including one a silent poll
|
||||
// is recovering from — otherwise Plan keeps its alert over good data.
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
if (cancelled || reqId !== latestReqId.current) return;
|
||||
console.warn("[useWallet] fetch failed", e);
|
||||
setError(e instanceof Error ? e.message : "Failed to load wallet");
|
||||
if (!silent) {
|
||||
console.warn("[useWallet] fetch failed", e);
|
||||
setError(e instanceof Error ? e.message : "Failed to load wallet");
|
||||
}
|
||||
// A failed background refresh is a non-event: the last good snapshot
|
||||
// stands and the next tick self-heals, so it neither surfaces nor
|
||||
// logs — otherwise an offline tab warns every WALLET_POLL_MS.
|
||||
} finally {
|
||||
// Deliberately not gated on `silent`: whichever load is latest owns
|
||||
// settling the flag, or a silent refresh that supersedes a visible one
|
||||
// leaves it stuck true.
|
||||
if (!cancelled && reqId === latestReqId.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -242,6 +284,46 @@ export function useWallet(): UseWalletResult {
|
||||
};
|
||||
}, [devPreview, refetchTick]);
|
||||
|
||||
// The wallet drains as automation, AI and API work runs, so a figure fetched
|
||||
// on mount goes stale while the user watches it. Refresh on a timer, and
|
||||
// immediately on returning to the tab — coming back to a stale number is the
|
||||
// case people actually notice. Hidden tabs don't poll, and the dev-preview
|
||||
// wallet is synthesised locally so there is nothing to re-read.
|
||||
useEffect(() => {
|
||||
if (devPreview) return;
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
const refresh = () => {
|
||||
silentRefresh.current = true;
|
||||
setRefetchTick((t) => t + 1);
|
||||
};
|
||||
const stop = () => {
|
||||
if (timer !== undefined) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
const start = () => {
|
||||
stop();
|
||||
timer = setInterval(refresh, WALLET_POLL_MS);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
refresh();
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.visibilityState === "visible") start();
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [devPreview]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setRefetchTick((t) => t + 1);
|
||||
// Snapshot the next-tick promise so the caller awaits this refetch
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AppLayout } from "@app/components/AppLayout";
|
||||
import { BannerProvider, useBanner } from "@app/contexts/BannerContext";
|
||||
import { NavigationProvider } from "@app/contexts/NavigationContext";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Components/AppLayout",
|
||||
@@ -49,7 +49,7 @@ function BannerSetter() {
|
||||
const { setBanner } = useBanner();
|
||||
useEffect(() => {
|
||||
setBanner(
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="info-rounded"
|
||||
title="Heads up"
|
||||
message="This workspace is running in offline mode."
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* App-wide top bar. One shape, four tones — callers pick a tone, never a colour.
|
||||
Named `app-banner`, not `sui-banner`: that belongs to the SUI Banner primitive. */
|
||||
.app-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 3.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
/* Full-bleed across the top of the app: square corners, one rule underneath. */
|
||||
border-bottom: 1px solid var(--app-banner-border);
|
||||
background: var(--app-banner-bg);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.app-banner--compact {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.app-banner--info {
|
||||
--app-banner-bg: var(--c-primary-subtle);
|
||||
--app-banner-border: var(--c-primary-border);
|
||||
--app-banner-icon: var(--c-accent-fg, var(--c-primary));
|
||||
}
|
||||
|
||||
/* The one bar meant to pop, so it takes the feature gradient rather than a tint.
|
||||
Fixed hues by design — it doesn't follow the chosen accent. */
|
||||
.app-banner--promo {
|
||||
--app-banner-bg: linear-gradient(
|
||||
135deg,
|
||||
var(--c-hue-indigo) 0%,
|
||||
var(--c-hue-purple) 100%
|
||||
);
|
||||
--app-banner-border: transparent;
|
||||
--app-banner-icon: var(--color-text-on-accent);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.app-banner--warning {
|
||||
--app-banner-bg: var(--c-warning-subtle);
|
||||
--app-banner-border: color-mix(in srgb, var(--c-warning) 32%, transparent);
|
||||
--app-banner-icon: var(--c-warning);
|
||||
}
|
||||
|
||||
.app-banner--danger {
|
||||
--app-banner-bg: var(--c-danger-subtle);
|
||||
--app-banner-border: color-mix(in srgb, var(--c-danger) 32%, transparent);
|
||||
--app-banner-icon: var(--c-danger);
|
||||
}
|
||||
|
||||
/* Only the icon carries the tone; text stays neutral in every tone. */
|
||||
.app-banner__icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
color: var(--app-banner-icon);
|
||||
}
|
||||
|
||||
.app-banner__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.app-banner__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-banner__message {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.app-banner__body:not(:has(.app-banner__title)) .app-banner__message {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-banner--compact .app-banner__title,
|
||||
.app-banner--compact .app-banner__message {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* On the gradient everything is white; muted grey would disappear. */
|
||||
.app-banner--promo .app-banner__message,
|
||||
.app-banner--promo .app-banner__actions .sui-btn--tertiary,
|
||||
.app-banner--promo .app-banner__actions .sui-ai {
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
/* Lifts the premium CTA off the gradient it sits on. */
|
||||
.app-banner--promo .app-banner__actions .sui-btn--primary {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.app-banner__actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mantine trims the leading padding when a button has a left section, which reads
|
||||
as off-centre next to the label. Even it back up. */
|
||||
.app-banner__actions .sui-btn .mantine-Button-inner {
|
||||
padding-inline: 0;
|
||||
}
|
||||
.app-banner__actions .sui-btn {
|
||||
padding-inline: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/AppBanner",
|
||||
component: AppBanner,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof AppBanner>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
icon: "info-rounded",
|
||||
title: "Heads up",
|
||||
message: "This document contains form fields that will be flattened.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Promo: Story = {
|
||||
args: {
|
||||
tone: "promo",
|
||||
icon: "stars-rounded",
|
||||
title: "Upgrade to Server Plan",
|
||||
message:
|
||||
"Get the most out of Stirling PDF with unlimited users and advanced features.",
|
||||
buttonText: "Upgrade Now",
|
||||
buttonIcon: "upgrade-rounded",
|
||||
onButtonClick: () => {},
|
||||
compact: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
tone: "warning",
|
||||
icon: "warning-rounded",
|
||||
title: "Action required",
|
||||
message: "Some pages could not be processed and were skipped.",
|
||||
buttonText: "Review",
|
||||
onButtonClick: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
tone: "danger",
|
||||
icon: "warning-rounded",
|
||||
title: "This server needs admin attention",
|
||||
message: "Review the license requirements to keep this server compliant.",
|
||||
buttonText: "See info",
|
||||
buttonIcon: "info-rounded",
|
||||
onButtonClick: () => {},
|
||||
dismissible: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
icon: "info-rounded",
|
||||
message: "Autosave is enabled for this file.",
|
||||
dismissible: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** Message-only, no title: the message takes the title's weight so the bar still reads. */
|
||||
export const MessageOnly: Story = {
|
||||
args: {
|
||||
icon: "picture-as-pdf-rounded",
|
||||
message:
|
||||
"Make Stirling PDF your default application for opening PDF files.",
|
||||
buttonText: "Set Default",
|
||||
onButtonClick: () => {},
|
||||
secondaryButtonText: "Don't remind me again",
|
||||
onSecondaryButtonClick: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
function Row({ caption, children }: { caption: string; children: ReactNode }) {
|
||||
return (
|
||||
<section
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.6875rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--c-text-subtle)",
|
||||
padding: "0 1rem",
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</span>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every top bar the app can show, in one place: each entry mirrors a real caller,
|
||||
* so a change to the component is visible against the whole set at once. Renders a
|
||||
* composition rather than the component, so it takes no args of its own.
|
||||
*/
|
||||
export const AllTopBars: StoryObj = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.5rem",
|
||||
padding: "1.5rem 0",
|
||||
background: "var(--c-bg)",
|
||||
}}
|
||||
>
|
||||
<Row caption="Upgrade prompt · UpgradeBanner (friendly)">
|
||||
<AppBanner
|
||||
tone="promo"
|
||||
compact
|
||||
icon="stars-rounded"
|
||||
title="Upgrade to Server Plan"
|
||||
message="Get the most out of Stirling PDF with unlimited users and advanced features."
|
||||
buttonText="Upgrade Now"
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={() => {}}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Server needs attention · UpgradeBanner (urgent)">
|
||||
<AppBanner
|
||||
tone="warning"
|
||||
icon="warning-rounded"
|
||||
title="This server needs admin attention"
|
||||
message="Review the license requirements to keep this server compliant."
|
||||
buttonText="See info"
|
||||
buttonIcon="info-rounded"
|
||||
onButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Free tier limit reached · AdminPlanSection">
|
||||
<AppBanner
|
||||
tone="warning"
|
||||
icon="warning-rounded"
|
||||
title="Free self-hosted limit reached"
|
||||
message="You have 12 users on a plan that covers 10."
|
||||
buttonText="See plans"
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Team invitation · TeamInvitationBanner">
|
||||
<AppBanner
|
||||
icon="mail"
|
||||
message="You have been invited to join the Acme Legal team."
|
||||
buttonText="Accept"
|
||||
onButtonClick={() => {}}
|
||||
secondaryButtonText="Decline"
|
||||
onSecondaryButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Set as default app · DefaultAppBanner (desktop)">
|
||||
<AppBanner
|
||||
icon="picture-as-pdf-rounded"
|
||||
message="Make Stirling PDF your default application for opening PDF files."
|
||||
buttonText="Set Default"
|
||||
onButtonClick={() => {}}
|
||||
secondaryButtonText="Don't remind me again"
|
||||
onSecondaryButtonClick={() => {}}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Danger tone (available, no caller yet)">
|
||||
<AppBanner
|
||||
tone="danger"
|
||||
icon="warning-rounded"
|
||||
title="Storage is full"
|
||||
message="New uploads will fail until space is freed."
|
||||
buttonText="Manage storage"
|
||||
onButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import "@app/components/shared/AppBanner.css";
|
||||
|
||||
/** Picks the whole look. Callers choose meaning, never colours. */
|
||||
export type AppBannerTone = "info" | "promo" | "warning" | "danger";
|
||||
|
||||
/** Tone decides the button too, so the CTA can't drift from the bar it sits on. */
|
||||
const TONE_BUTTON = {
|
||||
info: { variant: "secondary", accent: "default" },
|
||||
promo: { variant: "primary", accent: "premium" },
|
||||
warning: { variant: "primary", accent: "warning" },
|
||||
danger: { variant: "primary", accent: "danger" },
|
||||
} as const;
|
||||
|
||||
interface AppBannerProps {
|
||||
/** A LocalIcon name, or a pre-rendered node (e.g. a logo) dropped in as-is. */
|
||||
icon?: string | ReactNode;
|
||||
title?: ReactNode;
|
||||
message: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonIcon?: string;
|
||||
onButtonClick?: () => void;
|
||||
/** Muted secondary action, e.g. "Don't remind me again". */
|
||||
secondaryButtonText?: string;
|
||||
onSecondaryButtonClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
dismissible?: boolean;
|
||||
loading?: boolean;
|
||||
show?: boolean;
|
||||
tone?: AppBannerTone;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** The app's top bar: dismissible messaging above the workspace. */
|
||||
export const AppBanner: React.FC<AppBannerProps> = ({
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = "check-circle-rounded",
|
||||
onButtonClick,
|
||||
secondaryButtonText,
|
||||
onSecondaryButtonClick,
|
||||
onDismiss,
|
||||
dismissible = true,
|
||||
loading = false,
|
||||
show = true,
|
||||
tone = "info",
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (!show) return null;
|
||||
|
||||
const iconSize = compact ? "1rem" : "1.25rem";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"app-banner",
|
||||
`app-banner--${tone}`,
|
||||
compact ? "app-banner--compact" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{icon != null && (
|
||||
<span className="app-banner__icon" aria-hidden>
|
||||
{typeof icon === "string" ? (
|
||||
<LocalIcon icon={icon} width={iconSize} height={iconSize} />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="app-banner__body">
|
||||
{title && <span className="app-banner__title">{title}</span>}
|
||||
<span className="app-banner__message">{message}</span>
|
||||
</div>
|
||||
|
||||
<div className="app-banner__actions">
|
||||
{buttonText && onButtonClick && (
|
||||
<Button
|
||||
variant={TONE_BUTTON[tone].variant}
|
||||
accent={TONE_BUTTON[tone].accent}
|
||||
size="sm"
|
||||
loading={loading}
|
||||
onClick={onButtonClick}
|
||||
leftSection={
|
||||
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryButtonText && onSecondaryButtonClick && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={onSecondaryButtonClick}
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{dismissible && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={() => onDismiss?.()}
|
||||
aria-label={t("appBanner.dismiss", "Dismiss")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -48,9 +48,55 @@
|
||||
transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428);
|
||||
}
|
||||
|
||||
/* One-shot "thinking" drift — the two parallelograms swap past each other and
|
||||
settle back. Same motion the chat FAB loops while the agent works, but this
|
||||
pair starts and ends at rest (translate 0, full opacity) so a single
|
||||
iteration can end without snapping. Callers apply it for one beat; see
|
||||
NavFooter.css for the hover use. */
|
||||
@keyframes sui-brandmark-drift-a {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
25% {
|
||||
transform: translate(-1px, -5px);
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-6px, 0);
|
||||
opacity: 0.9;
|
||||
}
|
||||
75% {
|
||||
transform: translate(-1px, 5px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sui-brandmark-drift-b {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
25% {
|
||||
transform: translate(1px, 5px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
50% {
|
||||
transform: translate(6px, 0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
75% {
|
||||
transform: translate(1px, -5px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sui-brandmark__a,
|
||||
.sui-brandmark__b {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,16 +75,13 @@
|
||||
padding: 0.25rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.file-sidebar-footer-box {
|
||||
padding: 0.25rem 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* The footer is the shared <NavFooter>: it brings its own boxes and padding,
|
||||
so this class only positions it in the column. */
|
||||
|
||||
/* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and
|
||||
let the boxes stack at the top — controls, then the settings footer right
|
||||
after — instead of the files box stretching to fill. */
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-controls,
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box {
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-controls {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-files-box {
|
||||
@@ -538,86 +535,3 @@
|
||||
pointer-events: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ---- Bottom bar (user + settings) ---- */
|
||||
.file-sidebar-bottom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* Bottom bar settings icon tracks the right edge during collapse animation */
|
||||
|
||||
.file-sidebar-bottom-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--c-accent-text);
|
||||
color: var(--c-text-on-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* No colored disc behind an actual photo; keep it for the initials fallback. */
|
||||
.file-sidebar-bottom-avatar--picture {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--c-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-bar[role="button"]:hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-bar[role="button"]:focus-visible {
|
||||
outline: 2px solid var(--c-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--c-text-subtle);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar {
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
@@ -22,13 +22,15 @@ import {
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
|
||||
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
||||
import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
|
||||
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
|
||||
import { useOpenPlan } from "@app/hooks/useOpenPlan";
|
||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||
import {
|
||||
useIndexedDB,
|
||||
useIndexedDBRevision,
|
||||
} from "@app/contexts/IndexedDBContext";
|
||||
import { accountService } from "@app/services/accountService";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
@@ -37,8 +39,7 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
|
||||
import { useLabelName } from "@app/data/labelDisplay";
|
||||
@@ -241,43 +242,11 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const { addFiles } = useFileHandler();
|
||||
const indexedDB = useIndexedDB();
|
||||
|
||||
// Each auth layer derives its own displayName from its native user shape.
|
||||
// Fall back to the proprietary REST endpoint only when the auth
|
||||
// context yields nothing - then to "User" as a generic last resort.
|
||||
const { displayName: authDisplayName, isAnonymous } = useAuth();
|
||||
const [accountUsername, setAccountUsername] = useState<string | null>(null);
|
||||
const displayName =
|
||||
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User");
|
||||
|
||||
const profilePictureUrl = useProfilePictureUrl();
|
||||
const [pictureFailed, setPictureFailed] = useState(false);
|
||||
useEffect(() => setPictureFailed(false), [profilePictureUrl]);
|
||||
const showProfilePicture = !!profilePictureUrl && !pictureFailed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.enableLogin) {
|
||||
setAccountUsername(null);
|
||||
return;
|
||||
}
|
||||
if (authDisplayName) {
|
||||
// The auth context has a name; don't bother hitting the REST
|
||||
// endpoint, but clear any stale cached value from a prior call.
|
||||
setAccountUsername(null);
|
||||
return;
|
||||
}
|
||||
accountService
|
||||
.getAccountData()
|
||||
.then((data) => {
|
||||
// Always reflect the latest result - including clearing it on
|
||||
// sign-out, when the endpoint returns no username (or 401s into
|
||||
// the catch branch below). Without this, signing out would leave
|
||||
// the old username on screen.
|
||||
setAccountUsername(data?.username ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
setAccountUsername(null);
|
||||
});
|
||||
}, [config?.enableLogin, authDisplayName]);
|
||||
const { displayName, profilePictureUrl, isAnonymous } =
|
||||
useAccountIdentity();
|
||||
const credits = useFreeCreditsSummary();
|
||||
const otherApp = useOtherAppSwitch();
|
||||
const openPlan = useOpenPlan();
|
||||
|
||||
// Leaf files = user-visible files (excludes intermediate tool outputs)
|
||||
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
|
||||
@@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
)}
|
||||
data-testid="open-files-page"
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
|
||||
<OpenInFullIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
@@ -1264,70 +1233,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
{/* Getting-started checklist, floating above the footer (SaaS only). */}
|
||||
<SidebarChecklistSlot collapsed={collapsed} />
|
||||
|
||||
{/* Box 3 — account footer (avatar + name + settings). */}
|
||||
<NavSurface className="file-sidebar-footer-box">
|
||||
{/* Bottom bar: user name + settings */}
|
||||
<Tooltip
|
||||
label={
|
||||
onOpenSettings
|
||||
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
|
||||
: displayName
|
||||
}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className="file-sidebar-bottom-bar"
|
||||
onClick={onOpenSettings}
|
||||
role={onOpenSettings ? "button" : undefined}
|
||||
tabIndex={onOpenSettings ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onOpenSettings
|
||||
? (e) => e.key === "Enter" && onOpenSettings()
|
||||
: undefined
|
||||
}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={
|
||||
onOpenSettings
|
||||
? t("fileSidebar.openSettings", "Open settings")
|
||||
: displayName
|
||||
}
|
||||
style={onOpenSettings ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-bottom-avatar${
|
||||
showProfilePicture
|
||||
? " file-sidebar-bottom-avatar--picture"
|
||||
: ""
|
||||
}`}
|
||||
aria-label={displayName}
|
||||
>
|
||||
{showProfilePicture ? (
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
className="file-sidebar-bottom-avatar-img"
|
||||
onError={() => setPictureFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
displayName.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-bottom-name sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<div className="file-sidebar-bottom-settings">
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</NavSurface>
|
||||
{/* Box 3 — the shared footer: credits, app switch, account row. */}
|
||||
<NavFooter
|
||||
className="file-sidebar-footer-box"
|
||||
displayName={displayName}
|
||||
profilePictureUrl={profilePictureUrl}
|
||||
onOpenSettings={onOpenSettings}
|
||||
credits={credits}
|
||||
onOpenPlan={openPlan ?? undefined}
|
||||
otherApp={otherApp}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/InfoBanner",
|
||||
component: InfoBanner,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof InfoBanner>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
icon: "info-rounded",
|
||||
title: "Heads up",
|
||||
message: "This document contains form fields that will be flattened.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
tone: "warning",
|
||||
icon: "warning-rounded",
|
||||
title: "Action required",
|
||||
message: "Some pages could not be processed and were skipped.",
|
||||
buttonText: "Review",
|
||||
onButtonClick: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
icon: "info-rounded",
|
||||
message: "Autosave is enabled for this file.",
|
||||
dismissible: false,
|
||||
},
|
||||
};
|
||||
@@ -1,263 +0,0 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Paper, Group, Text, Stack } from "@mantine/core";
|
||||
import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
type InfoBannerTone = "info" | "warning";
|
||||
|
||||
const toneStyles: Record<
|
||||
InfoBannerTone,
|
||||
{
|
||||
background: string;
|
||||
border: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
buttonColor: string;
|
||||
}
|
||||
> = {
|
||||
info: {
|
||||
background: "var(--mantine-color-blue-0)",
|
||||
border: "var(--mantine-color-blue-2)",
|
||||
text: "var(--mantine-color-blue-9)",
|
||||
icon: "var(--mantine-color-blue-6)",
|
||||
buttonColor: "blue",
|
||||
},
|
||||
warning: {
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "var(--mantine-color-orange-3)",
|
||||
text: "var(--color-amber-dark)",
|
||||
icon: "var(--mantine-color-orange-7)",
|
||||
buttonColor: "orange",
|
||||
},
|
||||
};
|
||||
|
||||
function toSharedButtonVariant(
|
||||
variant: "light" | "filled" | "white" | "outline" | "subtle",
|
||||
): ButtonVariant {
|
||||
switch (variant) {
|
||||
case "filled":
|
||||
return "primary";
|
||||
case "outline":
|
||||
return "secondary";
|
||||
case "subtle":
|
||||
return "tertiary";
|
||||
case "light":
|
||||
case "white":
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
function toSharedButtonAccent(color: string | undefined): ButtonAccent {
|
||||
// Mantine colours may carry a shade suffix (e.g. "orange.7"); use the hue.
|
||||
const hue = (color ?? "").split(".")[0];
|
||||
switch (hue) {
|
||||
case "red":
|
||||
return "danger";
|
||||
case "green":
|
||||
return "success";
|
||||
case "yellow":
|
||||
case "orange":
|
||||
return "warning";
|
||||
case "blue":
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
interface InfoBannerProps {
|
||||
/**
|
||||
* Either a LocalIcon name (string) for the standard sized icon slot, or a
|
||||
* pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is.
|
||||
*/
|
||||
icon?: string | ReactNode;
|
||||
title?: ReactNode;
|
||||
message: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonIcon?: string;
|
||||
onButtonClick?: () => void;
|
||||
/** Optional muted secondary action (e.g. "Don't remind me again"). */
|
||||
secondaryButtonText?: string;
|
||||
onSecondaryButtonClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
dismissible?: boolean;
|
||||
loading?: boolean;
|
||||
show?: boolean;
|
||||
tone?: InfoBannerTone;
|
||||
background?: string;
|
||||
borderColor?: string;
|
||||
textColor?: string;
|
||||
iconColor?: string;
|
||||
buttonColor?: string;
|
||||
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
|
||||
/** Override the button label colour (for dark/custom theme variants). */
|
||||
buttonTextColor?: string;
|
||||
minHeight?: number | string;
|
||||
closeIconColor?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic info banner component for displaying dismissible messages at the top of the app
|
||||
*/
|
||||
export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = "check-circle-rounded",
|
||||
onButtonClick,
|
||||
secondaryButtonText,
|
||||
onSecondaryButtonClick,
|
||||
onDismiss,
|
||||
dismissible = true,
|
||||
loading = false,
|
||||
show = true,
|
||||
tone = "info",
|
||||
background,
|
||||
borderColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
buttonColor,
|
||||
buttonVariant = "light",
|
||||
buttonTextColor,
|
||||
minHeight = 56,
|
||||
closeIconColor,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toneStyle = toneStyles[tone] ?? toneStyles.info;
|
||||
const resolvedTextColor = textColor ?? toneStyle.text;
|
||||
const handleDismiss = () => {
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
const iconSize = compact ? "1rem" : "1.2rem";
|
||||
const textSize = compact ? "xs" : "sm";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p={compact ? "xs" : "sm"}
|
||||
radius={0}
|
||||
style={{
|
||||
background: background ?? toneStyle.background,
|
||||
border: "none",
|
||||
borderBottom:
|
||||
borderColor === "transparent"
|
||||
? "none"
|
||||
: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
minHeight,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
justify="space-between"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group
|
||||
gap={compact ? "xs" : "sm"}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{icon != null &&
|
||||
(typeof icon === "string" ? (
|
||||
<LocalIcon
|
||||
icon={icon}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
))}
|
||||
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && (
|
||||
<Text
|
||||
fw={600}
|
||||
size={textSize}
|
||||
style={{ color: resolvedTextColor }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
fw={title ? 400 : 500}
|
||||
size={textSize}
|
||||
style={{ color: resolvedTextColor }}
|
||||
lineClamp={compact ? 1 : 2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
{buttonText && onButtonClick && (
|
||||
<Button
|
||||
variant={toSharedButtonVariant(buttonVariant)}
|
||||
accent={toSharedButtonAccent(
|
||||
buttonColor ?? toneStyle.buttonColor,
|
||||
)}
|
||||
size="sm"
|
||||
loading={loading}
|
||||
onClick={onButtonClick}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={buttonIcon}
|
||||
width={compact ? "0.75rem" : "0.9rem"}
|
||||
height={compact ? "0.75rem" : "0.9rem"}
|
||||
/>
|
||||
}
|
||||
style={buttonTextColor ? { color: buttonTextColor } : undefined}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryButtonText && onSecondaryButtonClick && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={onSecondaryButtonClick}
|
||||
style={{ color: "var(--c-text-muted)" }}
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{dismissible && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
aria-label={t("infoBanner.dismiss", "Dismiss")}
|
||||
style={{
|
||||
color: closeIconColor ?? "var(--c-text-muted)",
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width={compact ? "0.85rem" : "1rem"}
|
||||
height={compact ? "0.85rem" : "1rem"}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
/* Shared sidebar footer: one surface holding the link-account CTA, the credits
|
||||
meter, the other-app switch and the account row, hairline-separated.
|
||||
Structural only — every colour comes from a --c-* semantic token. */
|
||||
|
||||
.nav-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
/* Vertical only: the slots carry the horizontal padding so their separator
|
||||
runs the full width of the surface. */
|
||||
padding: 0.25rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-footer__slot {
|
||||
padding-inline: 0.375rem;
|
||||
}
|
||||
|
||||
/* Separators are drawn by the slots themselves, never as their own elements.
|
||||
A slot whose contents render nothing (the link-account CTA returns null once
|
||||
the org is linked, and an element is truthy even when it renders null) is
|
||||
:empty, so it is skipped by both rules below — it can't leave a line behind,
|
||||
and it can't push one to the top or bottom of the surface. A rule that only
|
||||
ever matches a slot PRECEDED by another visible slot cannot draw a leading
|
||||
separator, whatever the caller passes in. */
|
||||
.nav-footer__slot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-footer__slot:not(:empty) ~ .nav-footer__slot:not(:empty) {
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Fades the rows up on the first footer mount of a page session only. They are
|
||||
seeded from cache, so they're already present at first paint; replaying this
|
||||
on every later mount (switching apps, remounting a view) would animate
|
||||
content that never changed and read as a twitch. */
|
||||
@keyframes nav-footer-row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
|
||||
animation: nav-footer-row-in var(--motion-enter) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Rows (link-account, credits, switch, account) ---- */
|
||||
|
||||
.nav-footer__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: none;
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-footer__row:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nav-footer__row:not(:disabled):hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.nav-footer__row:focus-visible {
|
||||
outline: 2px solid var(--c-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.nav-footer__row-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.625rem;
|
||||
}
|
||||
|
||||
/* Hovering the switch row plays the mark's "thinking" drift once — the same
|
||||
motion the chat FAB loops, for a single beat, as a hint that the row hands
|
||||
off to the other app. One iteration only: it starts and ends at rest, so
|
||||
nothing snaps when it finishes, and re-entering the row replays it. */
|
||||
.nav-footer__row:hover .sui-brandmark__a {
|
||||
animation: sui-brandmark-drift-a 1.1s ease-in-out 1;
|
||||
}
|
||||
.nav-footer__row:hover .sui-brandmark__b {
|
||||
animation: sui-brandmark-drift-b 1.1s ease-in-out 1;
|
||||
}
|
||||
|
||||
.nav-footer__row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Trailing affordance on a row: the account row's gear, the switch row's
|
||||
leaving-this-app arrow. */
|
||||
.nav-footer__trailing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
/* Rows contributed by a caller (the link-account NavItem) sit in the same
|
||||
surface, so match this footer's row metrics rather than the nav rail's. */
|
||||
.nav-footer .sui-navitem {
|
||||
min-height: 2.25rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
margin: 0;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* ---- Collapsed icon rail ---- */
|
||||
|
||||
.nav-footer[data-collapsed] .nav-footer__slot {
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-footer[data-collapsed] .nav-footer__row {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.nav-footer[data-collapsed] .sui-navitem {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import { NavItem } from "@app/ui/NavItem";
|
||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||
|
||||
/** Stands in for a CTA that has decided it has nothing to show. */
|
||||
function RendersNothing() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof NavFooter> = {
|
||||
title: "Shared/NavFooter",
|
||||
component: NavFooter,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
displayName: "admin",
|
||||
onOpenSettings: () => {},
|
||||
credits: { remaining: 247, total: 500 },
|
||||
onOpenPlan: () => {},
|
||||
otherApp: { app: "processor", onOpen: () => {} },
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
width: "16.25rem",
|
||||
background: "var(--c-bg)",
|
||||
padding: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NavFooter>;
|
||||
|
||||
/** The editor's footer: credits, "Open PDF Processor", the account row. */
|
||||
export const InEditor: Story = {};
|
||||
|
||||
/** The processor's footer. Same three boxes, opposite switch target. */
|
||||
export const InProcessor: Story = {
|
||||
args: { otherApp: { app: "editor", onOpen: () => {} } },
|
||||
};
|
||||
|
||||
/** Self-hosted processor: no wallet, so no meter, and the link-account CTA
|
||||
* rides along in the account box. */
|
||||
export const WithLinkAccountCta: Story = {
|
||||
args: {
|
||||
credits: null,
|
||||
otherApp: { app: "editor", onOpen: () => {} },
|
||||
accountExtras: (
|
||||
<NavItem
|
||||
id="account-link"
|
||||
label="Link Stirling account"
|
||||
icon={<LinkIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** Regression guard: the processor always passes its link-account CTA, but that
|
||||
* component renders null once the org is linked. An element is truthy even
|
||||
* when it renders nothing, so this must not leave a separator above the first
|
||||
* visible row. */
|
||||
export const ExtrasThatRenderNothing: Story = {
|
||||
args: { accountExtras: <RendersNothing /> },
|
||||
};
|
||||
|
||||
/** A real profile picture replaces the initials disc. */
|
||||
export const WithProfilePicture: Story = {
|
||||
args: {
|
||||
displayName: "Ada Lovelace",
|
||||
profilePictureUrl:
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="black"/><circle cx="32" cy="24" r="12" fill="white"/><ellipse cx="32" cy="56" rx="20" ry="16" fill="white"/></svg>',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** Credits running low — the dot and bar shift to the warning tone at 20% left. */
|
||||
export const CreditsLow: Story = {
|
||||
args: { credits: { remaining: 42, total: 500 } },
|
||||
};
|
||||
|
||||
/** Allowance exhausted. */
|
||||
export const CreditsExhausted: Story = {
|
||||
args: { credits: { remaining: 0, total: 500 } },
|
||||
};
|
||||
|
||||
/** Core OSS: no wallet, no second app, settings only. */
|
||||
export const MinimalBuild: Story = {
|
||||
args: { credits: null, otherApp: null },
|
||||
};
|
||||
|
||||
/** No settings handler — the account row is inert identity, not a button. */
|
||||
export const NoSettings: Story = {
|
||||
args: { onOpenSettings: undefined },
|
||||
};
|
||||
|
||||
/** Collapsed icon rail: labels become tooltips. */
|
||||
export const Collapsed: Story = {
|
||||
args: { collapsed: true },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
width: "3.5rem",
|
||||
background: "var(--c-bg)",
|
||||
padding: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||
|
||||
/** The footer's tooltips need Mantine's theme context. */
|
||||
function withProviders(ui: React.ReactNode) {
|
||||
return <MantineProvider>{ui}</MantineProvider>;
|
||||
}
|
||||
|
||||
function renderFooter() {
|
||||
const { container } = render(
|
||||
withProviders(
|
||||
<NavFooter
|
||||
displayName="admin"
|
||||
onOpenSettings={() => {}}
|
||||
credits={{ remaining: 247, total: 500 }}
|
||||
otherApp={{ app: "processor", onOpen: () => {} }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
return container.querySelector(".nav-footer") as HTMLElement;
|
||||
}
|
||||
|
||||
describe("NavFooter — enter animation", () => {
|
||||
it("plays once per page session, not on every remount", () => {
|
||||
// The rows are seeded from cache, so they're present at first paint. Every
|
||||
// later mount — switching apps, remounting a view — would otherwise replay
|
||||
// the fade on content that never changed, which reads as a twitch.
|
||||
expect(renderFooter().dataset.animate).toBe("true");
|
||||
cleanup();
|
||||
expect(renderFooter().dataset.animate).toBeUndefined();
|
||||
cleanup();
|
||||
expect(renderFooter().dataset.animate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavFooter — separators", () => {
|
||||
it("never renders a divider beside a row that renders nothing", () => {
|
||||
// Dividers are CSS between adjacent non-empty slots, so an extras element
|
||||
// that returns null (the linked org's link-account CTA) can't leave a line.
|
||||
const { container } = render(
|
||||
withProviders(
|
||||
<NavFooter
|
||||
displayName="admin"
|
||||
onOpenSettings={() => {}}
|
||||
credits={null}
|
||||
otherApp={null}
|
||||
accountExtras={<>{null}</>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const slots = container.querySelectorAll(".nav-footer__slot");
|
||||
const filled = [...slots].filter((s) => s.childElementCount > 0);
|
||||
expect(filled).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".nav-footer__divider")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import { Avatar, NavSurface } from "@app/ui";
|
||||
import { BrandMark } from "@app/components/shared/BrandMark";
|
||||
import { type AppSwitchTarget } from "@app/components/shared/AppSwitch";
|
||||
import {
|
||||
NavFooterCreditsRow,
|
||||
type NavFooterCredits,
|
||||
} from "@app/components/shared/navFooter/NavFooterCreditsRow";
|
||||
import "@app/components/shared/navFooter/NavFooter.css";
|
||||
|
||||
export interface NavFooterAppLink {
|
||||
/** The app this footer is NOT in — the one the row opens. */
|
||||
app: AppSwitchTarget;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export interface NavFooterProps {
|
||||
/** Name shown next to the avatar, and the source of its initials fallback. */
|
||||
displayName: string;
|
||||
/** Profile picture; initials are drawn when absent or the URL fails to load. */
|
||||
profilePictureUrl?: string | null;
|
||||
/** Omit to render the account row as static text (no settings affordance). */
|
||||
onOpenSettings?: () => void;
|
||||
/** Null/undefined hides the meter — builds with no wallet never show it. */
|
||||
credits?: NavFooterCredits | null;
|
||||
/** Opens the plan surface from the credits row; omit to leave it inert. */
|
||||
onOpenPlan?: () => void;
|
||||
/** Null/undefined hides the switch row — e.g. no access to the other app. */
|
||||
otherApp?: NavFooterAppLink | null;
|
||||
/** Extra rows above the account row (the self-hosted link-account CTA). */
|
||||
accountExtras?: ReactNode;
|
||||
/** Icon-rail state: labels collapse to tooltips. */
|
||||
collapsed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the enter animation has already played this page session. The rows
|
||||
* are seeded from cache now, so they're present from first paint and every
|
||||
* later mount — switching apps, remounting a view — would otherwise replay the
|
||||
* animation on content that never changed, which reads as the UI twitching.
|
||||
*/
|
||||
let hasPlayedEnter = false;
|
||||
|
||||
/**
|
||||
* The bottom section every sidebar ends with, shared by the editor and the
|
||||
* processor so both present the same rows. ONE surface, hairline-separated, in
|
||||
* this order:
|
||||
*
|
||||
* 1. caller-contributed rows (the self-hosted link-account CTA)
|
||||
* 2. free credits remaining
|
||||
* 3. "Open <the other app>"
|
||||
* 4. the account row — avatar, name, settings
|
||||
*
|
||||
* Purely presentational: each app resolves its own identity, wallet and
|
||||
* app-switch access and passes them in, so this file carries no build-specific
|
||||
* gating. A row whose data is absent is dropped, and so is the separator that
|
||||
* would have sat beside it.
|
||||
*/
|
||||
export function NavFooter({
|
||||
displayName,
|
||||
profilePictureUrl,
|
||||
onOpenSettings,
|
||||
credits,
|
||||
onOpenPlan,
|
||||
otherApp,
|
||||
accountExtras,
|
||||
collapsed = false,
|
||||
className,
|
||||
}: NavFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const [animate] = useState(() => {
|
||||
if (hasPlayedEnter) return false;
|
||||
hasPlayedEnter = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
const settingsLabel = t("fileSidebar.openSettings", "Open settings");
|
||||
const accountLabel = onOpenSettings
|
||||
? `${displayName} - ${settingsLabel}`
|
||||
: displayName;
|
||||
|
||||
// One surface, hairline-separated rows. Each row gets a slot; the separators
|
||||
// are drawn by CSS between adjacent NON-EMPTY slots (see NavFooter.css), so a
|
||||
// row that renders nothing — the link-account CTA returns null once the org is
|
||||
// linked, and an element is truthy even then — can't leave a line behind.
|
||||
const rows: Array<{ key: string; node: ReactNode }> = [];
|
||||
|
||||
if (accountExtras) rows.push({ key: "extras", node: accountExtras });
|
||||
|
||||
if (credits) {
|
||||
rows.push({
|
||||
key: "credits",
|
||||
node: (
|
||||
<NavFooterCreditsRow
|
||||
credits={credits}
|
||||
collapsed={collapsed}
|
||||
label={t("navFooter.credits.label", "Free credits")}
|
||||
onOpen={onOpenPlan}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (otherApp) {
|
||||
rows.push({
|
||||
key: "switch",
|
||||
node: (
|
||||
<Tooltip
|
||||
label={openAppLabel(otherApp.app, t)}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-footer__row"
|
||||
onClick={() => otherApp.onOpen()}
|
||||
// Collapsed drops the visible label, so name the button here too.
|
||||
aria-label={openAppLabel(otherApp.app, t)}
|
||||
>
|
||||
<span className="nav-footer__row-icon" aria-hidden>
|
||||
<BrandMark height="1.125rem" />
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="nav-footer__row-label">
|
||||
{openAppLabel(otherApp.app, t)}
|
||||
</span>
|
||||
{/* "Takes you there", not "opens a new tab" — both apps are
|
||||
one SPA, so this navigates in place. */}
|
||||
<span className="nav-footer__trailing" aria-hidden>
|
||||
<ArrowForwardIcon sx={{ fontSize: "1rem" }} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
rows.push({
|
||||
key: "account",
|
||||
node: (
|
||||
<Tooltip
|
||||
label={accountLabel}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-footer__row nav-footer__account"
|
||||
// Called with no args: handlers that take optional params (the
|
||||
// processor's openSettings(section?)) must not receive the event.
|
||||
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
|
||||
disabled={!onOpenSettings}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={accountLabel}
|
||||
>
|
||||
{/* Decorative: the button's own label already names the account, so
|
||||
an alt/label here would just repeat it to a screen reader. */}
|
||||
<span aria-hidden>
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={displayName}
|
||||
src={profilePictureUrl ?? undefined}
|
||||
/>
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="nav-footer__row-label sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<span className="nav-footer__trailing" aria-hidden>
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<NavSurface
|
||||
className={["nav-footer", className ?? ""].filter(Boolean).join(" ")}
|
||||
data-collapsed={collapsed || undefined}
|
||||
data-animate={animate || undefined}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="nav-footer__slot">
|
||||
{row.node}
|
||||
</div>
|
||||
))}
|
||||
</NavSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function openAppLabel(
|
||||
app: AppSwitchTarget,
|
||||
t: (key: string, fallback: string) => string,
|
||||
): string {
|
||||
return app === "editor"
|
||||
? t("navFooter.openEditor", "Open PDF Editor")
|
||||
: t("navFooter.openProcessor", "Open PDF Processor");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* Free-credits meter inside the sidebar footer. The row base (padding, hover,
|
||||
focus) comes from NavFooter.css; these rules are the meter itself. */
|
||||
|
||||
.nav-footer__credits {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.375rem;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Inert by default, so it must not read as hoverable; the actionable variant
|
||||
opts back into the shared row hover. */
|
||||
.nav-footer__credits:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.nav-footer__credits--actionable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-footer__credits--actionable:hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.nav-footer__credits-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.nav-footer__dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--c-success);
|
||||
}
|
||||
.nav-footer__dot[data-tone="warning"] {
|
||||
background-color: var(--c-warning);
|
||||
}
|
||||
.nav-footer__dot[data-tone="danger"] {
|
||||
background-color: var(--c-danger);
|
||||
}
|
||||
|
||||
.nav-footer__credits-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
color: var(--c-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nav-footer__credits-count {
|
||||
flex-shrink: 0;
|
||||
color: var(--c-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- Collapsed rail ---- */
|
||||
|
||||
/* Rotated so the fill starts at 12 o'clock and runs clockwise. */
|
||||
.nav-footer__credits-ring {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-inline: auto;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.nav-footer__credits-ring-track,
|
||||
.nav-footer__credits-ring-fill {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.nav-footer__credits-ring-track {
|
||||
stroke: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.nav-footer__credits-ring-fill {
|
||||
stroke-linecap: round;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { ProgressBar } from "@app/ui";
|
||||
import "@app/components/shared/navFooter/NavFooterCreditsRow.css";
|
||||
|
||||
export interface NavFooterCredits {
|
||||
/** Free credits still available to spend. */
|
||||
remaining: number;
|
||||
/** Size of the free allowance — the "of N" denominator. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Remaining-credit bands, mirroring the usage meters' 80% / 100% thresholds. */
|
||||
function creditsTone(remaining: number, total: number): string {
|
||||
if (remaining <= 0) return "danger";
|
||||
return total > 0 && remaining / total <= 0.2 ? "warning" : "success";
|
||||
}
|
||||
|
||||
interface NavFooterCreditsRowProps {
|
||||
credits: NavFooterCredits;
|
||||
/** Icon rail: the figures drop and the bar alone carries the state. */
|
||||
collapsed: boolean;
|
||||
/** Row label, passed in so the meter owns no copy of its own. */
|
||||
label: string;
|
||||
/** Opens the plan surface. Omit to render the meter as inert text. */
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The free-credits meter as it appears in the sidebar footer: a state dot, the
|
||||
* label, "X of Y" remaining, and a fill bar underneath. Figures are clamped
|
||||
* here so a wallet that reports more remaining than the allowance (or negative)
|
||||
* can't overflow the bar.
|
||||
*
|
||||
* Rendered as a {@code nav-footer__row}, so it inherits that row's metrics
|
||||
* from NavFooter.css and only brings its own meter styling.
|
||||
*/
|
||||
export function NavFooterCreditsRow({
|
||||
credits,
|
||||
collapsed,
|
||||
label,
|
||||
onOpen,
|
||||
}: NavFooterCreditsRowProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const total = Math.max(0, credits.total);
|
||||
const remaining = Math.min(Math.max(0, credits.remaining), total);
|
||||
const tone = creditsTone(remaining, total);
|
||||
const count = t("navFooter.credits.count", "{{remaining}} of {{total}}", {
|
||||
remaining: remaining.toLocaleString(),
|
||||
total: total.toLocaleString(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${label}: ${count}`}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<Row onOpen={onOpen} label={`${label}: ${count}`}>
|
||||
{collapsed ? (
|
||||
// The rail is one icon wide, so a full-width bar would read as a
|
||||
// stray line; a ring carries the same fraction at icon size.
|
||||
<CreditsRing
|
||||
fraction={total > 0 ? remaining / total : 0}
|
||||
tone={tone}
|
||||
label={`${label}: ${count}`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="nav-footer__credits-head">
|
||||
<span className="nav-footer__dot" data-tone={tone} aria-hidden />
|
||||
<span className="nav-footer__credits-label">{label}</span>
|
||||
<span className="nav-footer__credits-count">{count}</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={total > 0 ? remaining / total : 0}
|
||||
height={6}
|
||||
color={`var(--c-${tone})`}
|
||||
label={`${label}: ${count}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Icon-sized donut carrying the same remaining fraction as the expanded bar. */
|
||||
function CreditsRing({
|
||||
fraction,
|
||||
tone,
|
||||
label,
|
||||
}: {
|
||||
fraction: number;
|
||||
tone: string;
|
||||
label: string;
|
||||
}) {
|
||||
const RADIUS = 8;
|
||||
const circumference = 2 * Math.PI * RADIUS;
|
||||
const filled = Math.min(1, Math.max(0, fraction)) * circumference;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="nav-footer__credits-ring"
|
||||
viewBox="0 0 20 20"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<circle
|
||||
className="nav-footer__credits-ring-track"
|
||||
cx="10"
|
||||
cy="10"
|
||||
r={RADIUS}
|
||||
/>
|
||||
<circle
|
||||
className="nav-footer__credits-ring-fill"
|
||||
cx="10"
|
||||
cy="10"
|
||||
r={RADIUS}
|
||||
stroke={`var(--c-${tone})`}
|
||||
strokeDasharray={`${filled} ${circumference - filled}`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The meter is a button only where there is a plan surface to open — otherwise
|
||||
* it stays a plain div, so a build with nowhere to go doesn't advertise a
|
||||
* click that does nothing.
|
||||
*/
|
||||
function Row({
|
||||
onOpen,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
onOpen?: () => void;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const className = `nav-footer__row nav-footer__credits${
|
||||
onOpen ? " nav-footer__credits--actionable" : ""
|
||||
}`;
|
||||
if (!onOpen) return <div className={className}>{children}</div>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => onOpen()}
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+94
-59
@@ -1,5 +1,7 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Stack, Divider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ViewerContext } from "@app/contexts/ViewerContext";
|
||||
import {
|
||||
ChangeMetadataParameters,
|
||||
createCustomMetadataFunctions,
|
||||
@@ -19,6 +21,31 @@ interface ChangeMetadataSingleStepProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fills the form from the currently open document's existing metadata.
|
||||
* Isolated in its own component so it only mounts where a ViewerProvider exists
|
||||
* (the editor and the in-editor Automate modal). The pipeline builder has no
|
||||
* viewer and no single "current document", so it is skipped there rather than
|
||||
* crashing on useViewer.
|
||||
*/
|
||||
const MetadataPrefill = ({
|
||||
onParameterChange,
|
||||
onExtractingChange,
|
||||
}: {
|
||||
onParameterChange: ChangeMetadataSingleStepProps["onParameterChange"];
|
||||
onExtractingChange: (extracting: boolean) => void;
|
||||
}) => {
|
||||
const { isExtractingMetadata } = useMetadataExtraction({
|
||||
updateParameter: onParameterChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onExtractingChange(isExtractingMetadata);
|
||||
}, [isExtractingMetadata, onExtractingChange]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ChangeMetadataSingleStep = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
@@ -26,77 +53,85 @@ const ChangeMetadataSingleStep = ({
|
||||
}: ChangeMetadataSingleStepProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Auto-prefill reads the viewer/file contexts, which only exist in the editor.
|
||||
// Gate on the viewer so the pipeline builder renders the fields without it.
|
||||
const hasViewerContext = useContext(ViewerContext) !== null;
|
||||
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
|
||||
|
||||
// Get custom metadata functions using the utility
|
||||
const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } =
|
||||
createCustomMetadataFunctions(parameters, onParameterChange);
|
||||
|
||||
// Extract metadata from uploaded files
|
||||
const { isExtractingMetadata } = useMetadataExtraction({
|
||||
updateParameter: onParameterChange,
|
||||
});
|
||||
|
||||
const isDeleteAllEnabled = parameters.deleteAll;
|
||||
const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Delete All */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
|
||||
</Text>
|
||||
<DeleteAllStep
|
||||
parameters={parameters}
|
||||
<>
|
||||
{hasViewerContext && (
|
||||
<MetadataPrefill
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
onExtractingChange={setIsExtractingMetadata}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Standard Metadata Fields */}
|
||||
)}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.standardFields.title", "Standard Metadata")}
|
||||
</Text>
|
||||
<StandardMetadataStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
/>
|
||||
{/* Delete All */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
|
||||
</Text>
|
||||
<DeleteAllStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Standard Metadata Fields */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.standardFields.title", "Standard Metadata")}
|
||||
</Text>
|
||||
<StandardMetadataStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Document Dates */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.dates.title", "Document Dates")}
|
||||
</Text>
|
||||
<DocumentDatesStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.advanced.title", "Advanced Options")}
|
||||
</Text>
|
||||
<AdvancedOptionsStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
addCustomMetadata={addCustomMetadata}
|
||||
removeCustomMetadata={removeCustomMetadata}
|
||||
updateCustomMetadata={updateCustomMetadata}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Document Dates */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.dates.title", "Document Dates")}
|
||||
</Text>
|
||||
<DocumentDatesStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.advanced.title", "Advanced Options")}
|
||||
</Text>
|
||||
<AdvancedOptionsStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
addCustomMetadata={addCustomMetadata}
|
||||
removeCustomMetadata={removeCustomMetadata}
|
||||
updateCustomMetadata={updateCustomMetadata}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useContext, useRef } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { FilePicker } from "@app/ui/FilePicker";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,7 +17,7 @@ import {
|
||||
type OverlayMode,
|
||||
} from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { FilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import styles from "@app/components/tools/overlayPdfs/OverlayPdfsSettings.module.css";
|
||||
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
|
||||
|
||||
@@ -34,7 +36,12 @@ export default function OverlayPdfsSettings({
|
||||
disabled = false,
|
||||
}: OverlayPdfsSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
// Read optionally: the portal pipeline builder mounts no FilesModalProvider.
|
||||
// Present (editor tool + Automate modal) -> keep the workspace file picker;
|
||||
// absent (portal) -> fall back to the plain file input below.
|
||||
const filesModal = useContext(FilesModalContext);
|
||||
// Clears the FilePicker so the same file can be re-selected (Mantine resetRef).
|
||||
const resetOverlayPicker = useRef<() => void>(null);
|
||||
|
||||
const handleOverlayFilesChange = (files: File[]) => {
|
||||
onParameterChange("overlayFiles", files);
|
||||
@@ -66,8 +73,8 @@ export default function OverlayPdfsSettings({
|
||||
};
|
||||
|
||||
const handleOpenOverlayFilesModal = () => {
|
||||
if (disabled) return;
|
||||
openFilesModal({
|
||||
if (disabled || !filesModal) return;
|
||||
filesModal.openFilesModal({
|
||||
customHandler: (files: File[]) => {
|
||||
handleOverlayFilesChange([
|
||||
...(parameters.overlayFiles || []),
|
||||
@@ -77,6 +84,17 @@ export default function OverlayPdfsSettings({
|
||||
});
|
||||
};
|
||||
|
||||
const appendOverlayFiles = (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]);
|
||||
resetOverlayPicker.current?.();
|
||||
};
|
||||
|
||||
const overlayFilesButtonLabel =
|
||||
parameters.overlayFiles?.length > 0
|
||||
? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
|
||||
: t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="xs">
|
||||
@@ -183,17 +201,30 @@ export default function OverlayPdfsSettings({
|
||||
<Text size="sm" fw={500}>
|
||||
{t("overlay-pdfs.overlayFiles.label", "Overlay Files")}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleOpenOverlayFilesModal}
|
||||
disabled={disabled}
|
||||
leftSection={<LocalIcon icon="add" width="14" height="14" />}
|
||||
fullWidth
|
||||
>
|
||||
{parameters.overlayFiles?.length > 0
|
||||
? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
|
||||
: t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...")}
|
||||
</Button>
|
||||
{filesModal ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleOpenOverlayFilesModal}
|
||||
disabled={disabled}
|
||||
leftSection={<LocalIcon icon="add" width="14" height="14" />}
|
||||
fullWidth
|
||||
>
|
||||
{overlayFilesButtonLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<FilePicker
|
||||
multiple
|
||||
accept="application/pdf"
|
||||
onChange={appendOverlayFiles}
|
||||
resetRef={resetOverlayPicker}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
leftSection={<LocalIcon icon="add" width="14" height="14" />}
|
||||
fullWidth
|
||||
>
|
||||
{overlayFilesButtonLabel}
|
||||
</FilePicker>
|
||||
)}
|
||||
|
||||
{parameters.overlayFiles?.length > 0 &&
|
||||
(() => {
|
||||
|
||||
@@ -41,7 +41,9 @@ interface FilesModalContextType {
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||
export const FilesModalContext = createContext<FilesModalContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
|
||||
@@ -30,4 +30,15 @@ describe("automatable tools", () => {
|
||||
|
||||
expect(offeredWithoutConfig).toEqual([]);
|
||||
});
|
||||
|
||||
// Reorganize Pages has an automatable form (organization mode + page-order string) and a
|
||||
// context-free settings component, but its registry entry once left automationSettings null,
|
||||
// so both Automate and the pipeline builder showed "no configurable settings". Guard the wiring.
|
||||
test("Reorganize Pages exposes automation settings so it is configurable, not no-settings", () => {
|
||||
const { result } = renderHook(() => useTranslatedToolCatalog());
|
||||
|
||||
expect(
|
||||
result.current.regularTools.reorganizePages?.automationSettings,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -700,7 +700,10 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
endpoints: ["rearrange-pages"],
|
||||
operationConfig: asRegistryConfig(reorganizePagesOperationConfig),
|
||||
synonyms: getSynonyms(t, "reorganizePages"),
|
||||
automationSettings: null,
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/reorganizePages/ReorganizePagesSettings"),
|
||||
),
|
||||
},
|
||||
scalePages: {
|
||||
icon: (
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { accountService } from "@app/services/accountService";
|
||||
|
||||
export interface AccountIdentity {
|
||||
/** Never empty — falls back to a generic "User" so a row is never blank. */
|
||||
displayName: string;
|
||||
profilePictureUrl: string | null;
|
||||
isAnonymous: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in identity as the UI should draw it: one name and one picture,
|
||||
* resolved the same way everywhere. Every surface that shows "who am I" (the
|
||||
* editor and processor sidebar footers, the account settings page) reads this,
|
||||
* so a user can't see one initial in the sidebar and a different one in
|
||||
* settings.
|
||||
*
|
||||
* Resolution order for the name: the auth layer's own displayName (each layer
|
||||
* derives it from its native user shape), then the proprietary REST endpoint,
|
||||
* then a generic last resort.
|
||||
*/
|
||||
export function useAccountIdentity(): AccountIdentity {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { displayName: authDisplayName, isAnonymous } = useAuth();
|
||||
const profilePictureUrl = useProfilePictureUrl();
|
||||
const [accountUsername, setAccountUsername] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.enableLogin) {
|
||||
setAccountUsername(null);
|
||||
return;
|
||||
}
|
||||
if (authDisplayName) {
|
||||
// The auth context has a name; don't bother hitting the REST
|
||||
// endpoint, but clear any stale cached value from a prior call.
|
||||
setAccountUsername(null);
|
||||
return;
|
||||
}
|
||||
accountService
|
||||
.getAccountData()
|
||||
.then((data) => {
|
||||
// Always reflect the latest result - including clearing it on
|
||||
// sign-out, when the endpoint returns no username (or 401s into
|
||||
// the catch branch below). Without this, signing out would leave
|
||||
// the old username on screen.
|
||||
setAccountUsername(data?.username ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
setAccountUsername(null);
|
||||
});
|
||||
}, [config?.enableLogin, authDisplayName]);
|
||||
|
||||
return {
|
||||
displayName:
|
||||
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"),
|
||||
profilePictureUrl,
|
||||
isAnonymous,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
|
||||
|
||||
/**
|
||||
* Free credits left on this team's allowance, for the sidebar footer meter.
|
||||
* Null hides the meter entirely.
|
||||
*
|
||||
* Core has no wallet — self-hosted installs aren't metered — so there is
|
||||
* nothing to show. Cloud builds override this with the live wallet figure.
|
||||
*/
|
||||
export function useFreeCreditsSummary(): NavFooterCredits | null {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Opens the plan surface behind the sidebar footer's free-credits row, or null
|
||||
* when this build has none (the row is then inert text rather than a button).
|
||||
*
|
||||
* Core ships no wallet and no plan section, so there is nothing to open. Builds
|
||||
* that meter usage override this with their own surface.
|
||||
*/
|
||||
export function useOpenPlan(): (() => void) | null {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
|
||||
|
||||
/**
|
||||
* The sibling app this build can switch to (editor ⇄ processor), or null when
|
||||
* there is none. The single gate behind both the brand switcher and the
|
||||
* sidebar footer's "Open ..." row, so the two can never disagree about access.
|
||||
*
|
||||
* Core ships no processor, so there is nothing to switch to.
|
||||
*/
|
||||
export function useOtherAppSwitch(): NavFooterAppLink | null {
|
||||
return null;
|
||||
}
|
||||
@@ -6,5 +6,8 @@ export const qk = {
|
||||
["editor", "endpointEnabled", endpoint] as const,
|
||||
footerInfo: () => ["editor", "footerInfo"] as const,
|
||||
groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const,
|
||||
/** Keyed on the asking identity: two users must never share one answer. */
|
||||
portalAccess: (userId: string | null) =>
|
||||
["editor", "portalAccess", userId] as const,
|
||||
users: () => ["editor", "users"] as const,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Last-known sidebar-footer state, so the rows are correct at first paint
|
||||
* instead of arriving a request later.
|
||||
*
|
||||
* The footer is mounted by both apps, and the editor and processor are separate
|
||||
* React trees with separate query caches — so without this, every navigation
|
||||
* between them (and every remount inside them) re-ran the fetches and the rows
|
||||
* visibly popped in and shoved each other around. Persisting to storage rather
|
||||
* than to an in-memory cache is what makes it survive that boundary, and a
|
||||
* reload.
|
||||
*
|
||||
* Deliberately stale-then-revalidate: what's stored is only ever what the
|
||||
* backend last said, every reader refetches immediately and overwrites, and
|
||||
* nothing is gated on it — the processor enforces its own access server-side,
|
||||
* and a stale credit figure is replaced within a second of the wallet landing.
|
||||
*/
|
||||
const CREDITS_KEY = "stirling.navFooter.credits";
|
||||
const OTHER_APP_KEY = "stirling.navFooter.otherApp";
|
||||
|
||||
/** Figures, or null for a team that sees no meter at all (a paying one). */
|
||||
export type CachedCredits = { remaining: number; total: number } | null;
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
// Private mode / storage disabled — behave as a first-ever load.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Nothing to do: the cache is an optimisation, never a correctness input.
|
||||
}
|
||||
}
|
||||
|
||||
/** `undefined` when this browser has never seen an answer. */
|
||||
export function readCachedCredits(): CachedCredits | undefined {
|
||||
const raw = read(CREDITS_KEY);
|
||||
if (raw === null) return undefined;
|
||||
if (raw === "none") return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
typeof (parsed as CachedCredits & object).remaining === "number" &&
|
||||
typeof (parsed as CachedCredits & object).total === "number"
|
||||
) {
|
||||
return parsed as CachedCredits;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt entry — fall through and treat it as never-seen.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function writeCachedCredits(credits: CachedCredits): void {
|
||||
write(CREDITS_KEY, credits === null ? "none" : JSON.stringify(credits));
|
||||
}
|
||||
|
||||
/** `undefined` when this browser has never seen an answer. */
|
||||
export function readCachedOtherApp(): boolean | undefined {
|
||||
const raw = read(OTHER_APP_KEY);
|
||||
return raw === null ? undefined : raw === "true";
|
||||
}
|
||||
|
||||
export function writeCachedOtherApp(canOpen: boolean): void {
|
||||
write(OTHER_APP_KEY, String(canOpen));
|
||||
}
|
||||
@@ -71,6 +71,11 @@ html[data-app-theme="light"] {
|
||||
var(--c-success) 10%,
|
||||
var(--c-surface)
|
||||
);
|
||||
--c-warning-subtle: color-mix(
|
||||
in srgb,
|
||||
var(--c-warning) 10%,
|
||||
var(--c-surface)
|
||||
);
|
||||
|
||||
/* ── Decorative / brand / categorical palette ──────────────────────────
|
||||
Fixed hues that intentionally do NOT follow the chosen accent: brand
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
height: 2.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
/* Account-settings hero disc. */
|
||||
.sui-avatar--xl {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.sui-avatar__img {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import "@app/ui/Avatar.css";
|
||||
|
||||
export type AvatarSize = "xs" | "sm" | "md" | "lg";
|
||||
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
|
||||
export type AvatarTone =
|
||||
| "blue"
|
||||
| "purple"
|
||||
@@ -23,10 +24,12 @@ export interface AvatarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
function avatarInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
// Single word (a username or an email) reads as one letter — two letters of
|
||||
// "admin" ("AD") looks like a different person's initials, not a truncation.
|
||||
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
@@ -43,6 +46,13 @@ export function Avatar({
|
||||
ariaLabel,
|
||||
className,
|
||||
}: AvatarProps) {
|
||||
// A picture URL that 404s (expired signed URL, deleted upload) must not leave
|
||||
// an empty disc — fall back to the same initials the no-picture case shows, so
|
||||
// every surface rendering this identity agrees on what it draws.
|
||||
const [srcFailed, setSrcFailed] = useState(false);
|
||||
useEffect(() => setSrcFailed(false), [src]);
|
||||
const showImage = Boolean(src) && !srcFailed;
|
||||
|
||||
const classes = [
|
||||
"sui-avatar",
|
||||
`sui-avatar--${size}`,
|
||||
@@ -53,11 +63,16 @@ export function Avatar({
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const content = src ? (
|
||||
<img src={src} alt={ariaLabel ?? name} className="sui-avatar__img" />
|
||||
const content = showImage ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={ariaLabel ?? name}
|
||||
className="sui-avatar__img"
|
||||
onError={() => setSrcFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="sui-avatar__initials" aria-hidden>
|
||||
{initialsOf(name)}
|
||||
{avatarInitials(name)}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
import { useDefaultApp } from "@app/hooks/useDefaultApp";
|
||||
|
||||
export const DefaultAppBanner: React.FC = () => {
|
||||
@@ -15,7 +15,7 @@ export const DefaultAppBanner: React.FC = () => {
|
||||
const [sessionDismissed, setSessionDismissed] = useState(false);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="picture-as-pdf-rounded"
|
||||
message={t(
|
||||
"defaultApp.prompt.message",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* SaaS has no link concept — the signed-in account IS the SaaS account, and the
|
||||
* editor's cloud wallet hook is already in this build's {@code @app/*} cascade.
|
||||
* Delegating to it means the processor footer and the editor footer share one
|
||||
* wallet fetch and can't disagree, so there is nothing portal-specific to do.
|
||||
*/
|
||||
export { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useCallback } from "react";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
/**
|
||||
* SaaS processor: the settings modal it hosts carries the same Plan section the
|
||||
* editor opens, so the footer's credits row lands both apps in one place.
|
||||
*/
|
||||
export function useOpenPlan(): (() => void) | null {
|
||||
const { openSettings } = useUI();
|
||||
return useCallback(() => openSettings("plan"), [openSettings]);
|
||||
}
|
||||
@@ -123,8 +123,6 @@
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__footer {
|
||||
margin-inline: 0.375rem;
|
||||
padding-inline: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portal-sidebar__logo {
|
||||
@@ -179,10 +177,8 @@
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* The shared <NavFooter> brings its own boxes, padding and gap; the sidebar
|
||||
only positions it. */
|
||||
.portal-sidebar__footer {
|
||||
margin: 0 0.625rem 0.75rem;
|
||||
padding: 0.5rem 0.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useMediaQuery } from "@mantine/hooks";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, NavItem, NavSurface } from "@app/ui";
|
||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
||||
import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary";
|
||||
import { useOpenPlan } from "@portal/hooks/useOpenPlan";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -10,7 +14,7 @@ import { useUI } from "@portal/contexts/UIContext";
|
||||
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
|
||||
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
import { CloseIcon, SettingsIcon } from "@portal/components/icons";
|
||||
import { CloseIcon } from "@portal/components/icons";
|
||||
import {
|
||||
GROUP_PROCESSOR,
|
||||
GROUP_PLATFORM,
|
||||
@@ -41,6 +45,9 @@ export function Sidebar() {
|
||||
const isMobile = useMediaQuery(MOBILE_QUERY, false, {
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
const { displayName, profilePictureUrl } = useAccountIdentity();
|
||||
const credits = useFreeCreditsSummary();
|
||||
const openPlan = useOpenPlan();
|
||||
|
||||
// Collapse is a desktop-only affordance: on mobile the sidebar is an
|
||||
// off-canvas drawer, so the icon-rail state never applies there.
|
||||
@@ -146,15 +153,17 @@ export function Sidebar() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<NavSurface className="portal-sidebar__footer">
|
||||
<LinkAccountFooterItem />
|
||||
<NavItem
|
||||
id="settings"
|
||||
label={t("portal.nav.settings")}
|
||||
icon={<SettingsIcon />}
|
||||
onClick={() => openSettings()}
|
||||
/>
|
||||
</NavSurface>
|
||||
<NavFooter
|
||||
className="portal-sidebar__footer"
|
||||
displayName={displayName}
|
||||
profilePictureUrl={profilePictureUrl}
|
||||
onOpenSettings={openSettings}
|
||||
credits={credits}
|
||||
onOpenPlan={openPlan ?? undefined}
|
||||
otherApp={{ app: "editor", onOpen: goToEditor }}
|
||||
accountExtras={<LinkAccountFooterItem />}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import { formatPeriodDate, MeterBar, meterState } from "@app/billing";
|
||||
import { formatPeriodDate, MeterBar, remainingMeter } from "@app/billing";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
|
||||
/**
|
||||
@@ -10,8 +10,8 @@ import type { Wallet } from "@portal/api/billing";
|
||||
* - No bundle → a slim "Get 12 months for the price of 10" offer nudge with a
|
||||
* "Review offer" CTA (the demo's commit-nudge card), shown only when a buyer
|
||||
* ({@code onBuy}, leader) is present.
|
||||
* - Bundle held → the capacity meter (fills as the pool is drawn down, so it
|
||||
* warns as capacity runs low) plus a "Top up" action for the leader.
|
||||
* - Bundle held → the capacity meter (drains towards empty as the pool is drawn
|
||||
* down, so it warns as capacity runs low) plus a "Top up" action for the leader.
|
||||
*
|
||||
* Prepaid is consumed before metered billing and sits outside the spend limit, so
|
||||
* it reads as its own dimension. Buying/topping up opens {@code BundleCheckoutModal}
|
||||
@@ -55,8 +55,7 @@ export function PrepaidCapacityCard({
|
||||
|
||||
const remaining = wallet.prepaidUnitsRemaining;
|
||||
const total = wallet.prepaidUnitsTotal;
|
||||
const used = Math.max(0, total - remaining);
|
||||
const { state, pct } = meterState(used, total);
|
||||
const { state, pct } = remainingMeter(remaining, total);
|
||||
const stateLabel =
|
||||
state === "DEGRADED"
|
||||
? t("portal.billing.prepaid.state.exhausted", "Used up")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card } from "@app/ui";
|
||||
import { formatMinor, MeterBar, meterState } from "@app/billing";
|
||||
import { formatMinor, MeterBar, remainingMeter } from "@app/billing";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
import type { LocalUsage } from "@portal/api/link";
|
||||
|
||||
@@ -15,8 +15,10 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* The free Processor-trial meter — "X / N free PDFs used" against the one-time
|
||||
* grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
|
||||
* The free Processor-trial meter — "X of N free PDFs left" against the one-time
|
||||
* grant, with what has been used alongside as the status badge. The bar shows what
|
||||
* is left, so it drains towards empty as the grant is spent.
|
||||
* Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
|
||||
* cloud plan page). The subscribed spend-vs-cap meter is a separate surface
|
||||
* ({@code SpendLimitCard}); this card is only the free face.
|
||||
*
|
||||
@@ -30,7 +32,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
|
||||
const pending = unsynced?.totalUnsyncedUnits ?? 0;
|
||||
const used = wallet.billableUsed + pending;
|
||||
const remaining = Math.max(0, wallet.freeRemaining - pending);
|
||||
const { state, pct } = meterState(used, wallet.freeAllowance);
|
||||
const { state, pct } = remainingMeter(remaining, wallet.freeAllowance);
|
||||
const rate =
|
||||
wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0
|
||||
? wallet.pricePerDocMinor
|
||||
@@ -76,11 +78,14 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
barLabel={t("portal.billing.walletMeter.barAria", "Free PDFs used")}
|
||||
figure={used.toLocaleString()}
|
||||
barLabel={t(
|
||||
"portal.billing.walletMeter.barAria",
|
||||
"Free PDFs remaining",
|
||||
)}
|
||||
figure={remaining.toLocaleString()}
|
||||
capSuffix={t(
|
||||
"portal.billing.walletMeter.capSuffix",
|
||||
"of {{allowance}} free PDFs used",
|
||||
"of {{allowance}} free PDFs left",
|
||||
{
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
@@ -88,11 +93,8 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
|
||||
)}
|
||||
statusLabel={t(
|
||||
"portal.billing.walletMeter.statusLabel",
|
||||
"{{remaining}} left",
|
||||
{
|
||||
count: remaining,
|
||||
remaining: remaining.toLocaleString(),
|
||||
},
|
||||
"{{used}} used",
|
||||
{ count: used, used: used.toLocaleString() },
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useEffect, useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
Component,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
|
||||
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
|
||||
import {
|
||||
getExecutableTools,
|
||||
type WorkingToolStep,
|
||||
} from "@app/hooks/tools/shared/toolAutomation";
|
||||
import {
|
||||
asRegistryConfig,
|
||||
type ErasedToolParams,
|
||||
@@ -13,6 +26,10 @@ import {
|
||||
import ConvertSettings from "@app/components/tools/convert/ConvertSettings";
|
||||
import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOperation";
|
||||
import { defaultParameters as convertDefaults } from "@app/hooks/tools/convert/useConvertParameters";
|
||||
import ChangeMetadataSingleStep from "@app/components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||
import { defaultParameters as changeMetadataDefaults } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings";
|
||||
import { defaultParameters as overlayDefaults } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
|
||||
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
|
||||
|
||||
// Override only useTranslation; keep the rest of react-i18next (initReactI18next et al.) real, so
|
||||
@@ -21,6 +38,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
i18n: { language: "en-US", changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -63,6 +81,32 @@ const convertRegistry = {
|
||||
},
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
// The real Change Metadata automation settings. Its editor variant auto-prefills the
|
||||
// form from the open document via useViewer; that path is now gated on a ViewerProvider
|
||||
// so it renders here (the portal mounts none) instead of crashing on useViewer.
|
||||
const changeMetadataStep = {
|
||||
support: "editable",
|
||||
toolId: "changeMetadata",
|
||||
params: changeMetadataDefaults,
|
||||
} as unknown as WorkingToolStep;
|
||||
|
||||
const changeMetadataRegistry = {
|
||||
changeMetadata: { automationSettings: ChangeMetadataSingleStep },
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
// The real Overlay PDFs automation settings. Its overlay-file picker uses the
|
||||
// editor FilesModal when present; that read is now optional so the portal (which
|
||||
// mounts no FilesModalProvider) renders a plain file input instead of crashing.
|
||||
const overlayStep = {
|
||||
support: "editable",
|
||||
toolId: "overlayPdfs",
|
||||
params: overlayDefaults,
|
||||
} as unknown as WorkingToolStep;
|
||||
|
||||
const overlayRegistry = {
|
||||
overlayPdfs: { automationSettings: OverlayPdfsSettings },
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
describe("PipelineStepSettings", () => {
|
||||
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
|
||||
expect(() =>
|
||||
@@ -94,6 +138,36 @@ describe("PipelineStepSettings", () => {
|
||||
expect(screen.getByText(/Convert from/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Change Metadata tool's fields in the portal, with no ViewerProvider mounted", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<PortalTestProviders>
|
||||
<PipelineStepSettings
|
||||
step={changeMetadataStep}
|
||||
registry={changeMetadataRegistry}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</PortalTestProviders>,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(screen.getByText("Standard Metadata")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Overlay PDFs tool's fields in the portal, with no FilesModalProvider mounted", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<PortalTestProviders>
|
||||
<PipelineStepSettings
|
||||
step={overlayStep}
|
||||
registry={overlayRegistry}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</PortalTestProviders>,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(screen.getByText("Overlay Mode")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reproduces the convert-in-pipeline bug: picking a source format fires several onParameterChange
|
||||
// calls in one tick (set fromExtension, auto-target, reset options). If each rebuilt from the
|
||||
// step snapshot captured at render they'd clobber each other and the earlier field would be lost.
|
||||
@@ -148,3 +222,91 @@ describe("PipelineStepSettings", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Records a render crash and swallows it (renders nothing), so one broken tool is attributed by id
|
||||
// instead of aborting the whole sweep - mirroring the portal's own ErrorBoundary around the builder.
|
||||
class CaptureBoundary extends Component<
|
||||
{ onError: (error: Error) => void; children: ReactNode },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
componentDidCatch(error: Error) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? null : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Automated version of the manual "add every tool" sweep: render each tool's real automation
|
||||
// settings in a portal-only context (the same Preferences + Sidebar + Suspense wrappers
|
||||
// PipelineStepSettings uses, and NO editor providers) and fail listing any that throw. This is the
|
||||
// guard that would have caught Change Metadata (useViewer) and Overlay PDFs (useFilesModalContext).
|
||||
describe("PipelineStepSettings: every tool's settings render in the portal", () => {
|
||||
it("renders each tool's automation settings without throwing", async () => {
|
||||
const { result } = renderHook(() => useTranslatedToolCatalog());
|
||||
const catalog = result.current.allTools;
|
||||
// getExecutableTools is exactly what PipelineBuilder feeds its "Add a tool" picker, so this
|
||||
// sweeps precisely the tools a user can add. Narrow to "editable" (renders a settings
|
||||
// component); "noSettings"/"unsupported" steps show a Banner instead and can't crash.
|
||||
const editableTools = getExecutableTools(catalog)
|
||||
.filter((tool) => tool.support === "editable")
|
||||
.map((tool) => [tool.toolId, catalog[tool.toolId]] as const)
|
||||
.filter(([, entry]) => Boolean(entry?.automationSettings));
|
||||
// Guard against the filter silently matching nothing (e.g. a registry-shape change).
|
||||
expect(editableTools.length).toBeGreaterThan(10);
|
||||
|
||||
const failures: { toolId: string; message: string }[] = [];
|
||||
|
||||
for (const [toolId, entry] of editableTools) {
|
||||
const Settings = entry.automationSettings as ComponentType<
|
||||
ToolAutomationSettingsProps<ErasedToolParams>
|
||||
>;
|
||||
const params = (entry.operationConfig?.defaultParameters ??
|
||||
{}) as ErasedToolParams;
|
||||
|
||||
const caught: { error: Error | null } = { error: null };
|
||||
// The sentinel sibling commits only once the lazy Settings actually renders, so we wait for a
|
||||
// real render (or a caught throw) - not just the providers' wrapper DOM.
|
||||
const { unmount } = render(
|
||||
<PortalTestProviders>
|
||||
<PreferencesProvider>
|
||||
<SidebarProvider>
|
||||
<CaptureBoundary
|
||||
onError={(error) => {
|
||||
caught.error = error;
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={params}
|
||||
onParameterChange={() => {}}
|
||||
disabled={false}
|
||||
/>
|
||||
<span data-testid={`rendered-${toolId}`} />
|
||||
</Suspense>
|
||||
</CaptureBoundary>
|
||||
</SidebarProvider>
|
||||
</PreferencesProvider>
|
||||
</PortalTestProviders>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
caught.error !== null ||
|
||||
screen.queryByTestId(`rendered-${toolId}`) !== null,
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
if (caught.error) {
|
||||
failures.push({ toolId, message: caught.error.message });
|
||||
}
|
||||
unmount();
|
||||
}
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
|
||||
import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary";
|
||||
|
||||
const fetchWallet = vi.fn();
|
||||
vi.mock("@portal/api/billing", () => ({
|
||||
fetchWallet: () => fetchWallet(),
|
||||
}));
|
||||
|
||||
function Probe() {
|
||||
const credits = useFreeCreditsSummary();
|
||||
return (
|
||||
<span data-testid="credits">
|
||||
{credits ? `${credits.remaining}/${credits.total}` : "none"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFor(initialState: LinkState) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<LinkProvider initialState={initialState}>
|
||||
<Probe />
|
||||
</LinkProvider>
|
||||
</QueryClientProvider>,
|
||||
).getByTestId("credits");
|
||||
}
|
||||
|
||||
describe("useFreeCreditsSummary (self-hosted) — wallet behind the link gate", () => {
|
||||
beforeEach(() => {
|
||||
// The figures persist across mounts now, so isolate the suite from itself.
|
||||
localStorage.clear();
|
||||
fetchWallet.mockReset();
|
||||
fetchWallet.mockResolvedValue({
|
||||
status: "free",
|
||||
freeRemaining: 247,
|
||||
freeAllowance: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("unlinked reads no wallet at all", async () => {
|
||||
const el = renderFor("unlinked");
|
||||
await waitFor(() => expect(el.textContent).toBe("none"));
|
||||
expect(fetchWallet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("linked surfaces the free grant", async () => {
|
||||
const el = renderFor("linked-free");
|
||||
await waitFor(() => expect(el.textContent).toBe("247/500"));
|
||||
});
|
||||
|
||||
it("hides the meter once the team subscribes", async () => {
|
||||
// The grant is a lifetime pool that survives subscribing, so a paying team
|
||||
// would otherwise sit on a spent meter forever.
|
||||
fetchWallet.mockResolvedValue({
|
||||
status: "subscribed",
|
||||
freeRemaining: 0,
|
||||
freeAllowance: 500,
|
||||
});
|
||||
const el = renderFor("linked-subscribed");
|
||||
// The row holds its space while the wallet loads, then drops once the
|
||||
// answer says this team is paying.
|
||||
await waitFor(() => expect(el.textContent).toBe("none"));
|
||||
});
|
||||
|
||||
it("hides the meter when the wallet read fails", async () => {
|
||||
fetchWallet.mockRejectedValue(new Error("saas unreachable"));
|
||||
const el = renderFor("linked-subscribed");
|
||||
await waitFor(() => expect(el.textContent).toBe("none"));
|
||||
});
|
||||
|
||||
it("ignores cached figures once the instance is unlinked", async () => {
|
||||
// The cache survives an unlink and nothing rewrites it afterwards, so the
|
||||
// linkage gate has to cover the seed too, not just the fetch.
|
||||
const linked = renderFor("linked-free");
|
||||
await waitFor(() => expect(linked.textContent).toBe("247/500"));
|
||||
cleanup();
|
||||
|
||||
fetchWallet.mockClear();
|
||||
const unlinked = renderFor("unlinked");
|
||||
expect(unlinked.textContent).toBe("none");
|
||||
expect(fetchWallet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the last known figures while the wallet reloads", async () => {
|
||||
// What stops the row popping in — and resizing the footer — every time the
|
||||
// processor mounts.
|
||||
const el = renderFor("linked-free");
|
||||
await waitFor(() => expect(el.textContent).toBe("247/500"));
|
||||
cleanup();
|
||||
|
||||
let release: (v: unknown) => void = () => {};
|
||||
fetchWallet.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const second = renderFor("linked-free");
|
||||
// Seeded before the refetch lands...
|
||||
expect(second.textContent).toBe("247/500");
|
||||
release({ status: "free", freeRemaining: 12, freeAllowance: 500 });
|
||||
// ...then updated in place, without the row ever being absent.
|
||||
await waitFor(() => expect(second.textContent).toBe("12/500"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLink } from "@portal/contexts/LinkContext";
|
||||
import { fetchWallet } from "@portal/api/billing";
|
||||
import { qk } from "@portal/queries/keys";
|
||||
import {
|
||||
readCachedCredits,
|
||||
writeCachedCredits,
|
||||
type CachedCredits,
|
||||
} from "@app/services/navFooterCache";
|
||||
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
|
||||
|
||||
/**
|
||||
* Free credits left on this team's allowance, for the processor's sidebar
|
||||
* footer meter. Null hides the meter.
|
||||
*
|
||||
* This is the portal's own seam rather than the editor's {@code
|
||||
* @app/hooks/useFreeCreditsSummary}, because self-hosted resolves {@code @app/*}
|
||||
* as proprietary → core: the cloud wallet hook isn't in that cascade, and the
|
||||
* implementation can't move down into proprietary either, since core/desktop
|
||||
* builds ship no portal and must never resolve {@code @portal}. Keeping it here
|
||||
* means only builds that actually have a processor pull in the wallet read.
|
||||
*
|
||||
* Self-hosted reads the same {@code GET /api/v1/payg/wallet} the Usage page's
|
||||
* trial meter renders — {@code apiClient.saas} with the admin's Supabase JWT,
|
||||
* since the wallet lives in the cloud even when the instance doesn't. Gated on
|
||||
* linkage: an unlinked instance has no wallet to read.
|
||||
*
|
||||
* Free teams only, matching the editor and the Plan page. The grant is a
|
||||
* lifetime pool that survives subscribing, so a paying team would otherwise sit
|
||||
* on a permanent "0 of 500" in red; their usage lives on Usage & Billing.
|
||||
*/
|
||||
export function useFreeCreditsSummary(): NavFooterCredits | null {
|
||||
const { isLinked } = useLink();
|
||||
// Shared query key, so the footer rides the same cached snapshot as any other
|
||||
// wallet reader rather than adding a fetch per mount.
|
||||
const { data: wallet } = useQuery({
|
||||
queryKey: qk.wallet(isLinked),
|
||||
queryFn: fetchWallet,
|
||||
enabled: isLinked,
|
||||
});
|
||||
// Shared with the editor's seam, so crossing between the two apps shows the
|
||||
// figures the other one last saw rather than re-fetching into an empty row.
|
||||
const [seed] = useState(readCachedCredits);
|
||||
|
||||
const live: CachedCredits | undefined = !wallet
|
||||
? undefined
|
||||
: wallet.status === "subscribed"
|
||||
? null
|
||||
: { remaining: wallet.freeRemaining, total: wallet.freeAllowance };
|
||||
|
||||
useEffect(() => {
|
||||
// Only once linked: an unlinked instance never asks, so it has no answer of
|
||||
// its own and must not overwrite what the editor recorded.
|
||||
if (isLinked && live !== undefined) writeCachedCredits(live);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wallet, isLinked]);
|
||||
|
||||
// Linkage gates the seed as well as the fetch. The cache outlives an unlink
|
||||
// — nothing refetches or rewrites it once the instance stops asking — so
|
||||
// without this an unlinked instance would keep showing the figures from when
|
||||
// it was linked, indefinitely.
|
||||
if (!isLinked) return null;
|
||||
return (live !== undefined ? live : seed) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useCallback } from "react";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
|
||||
/**
|
||||
* Self-hosted processor: settings carries no Plan section (it is a cloud
|
||||
* surface, and this build's registry has none), so the footer's credits row
|
||||
* opens the portal's own Usage & Billing view instead — the same figures, on
|
||||
* the surface this flavor actually owns.
|
||||
*/
|
||||
export function useOpenPlan(): (() => void) | null {
|
||||
const { setActiveView } = useView();
|
||||
return useCallback(() => setActiveView("usage"), [setActiveView]);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export const qk = {
|
||||
// Keyed on linkage: an unlinked account has no deal to read, so linking must not
|
||||
// serve the unlinked (null) snapshot back from cache.
|
||||
procurement: (linked: boolean) => ["portal", "procurement", linked] as const,
|
||||
// Same reasoning: an unlinked instance has no wallet in the cloud.
|
||||
wallet: (linked: boolean) => ["portal", "wallet", linked] as const,
|
||||
|
||||
// Tier-dependent
|
||||
documents: (tier: Tier) => ["portal", "documents", tier] as const,
|
||||
|
||||
@@ -163,6 +163,10 @@ export const mantineTheme = createTheme({
|
||||
CloseButton: { defaultProps: { "aria-label": "Close" } },
|
||||
Modal: { defaultProps: { closeButtonProps: { "aria-label": "Close" } } },
|
||||
Drawer: { defaultProps: { closeButtonProps: { "aria-label": "Close" } } },
|
||||
// The portal's md default radius (8px) is right for cards and buttons but
|
||||
// rounds a 20px checkbox into a circle. Pin it to the smaller radius the
|
||||
// editor's checkboxes use so the box reads as a checkbox.
|
||||
Checkbox: { styles: { input: { borderRadius: "var(--radius-sm)" } } },
|
||||
},
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontFamilyMonospace: "var(--font-mono)",
|
||||
|
||||
@@ -293,6 +293,29 @@ export function computeBundleQuote(
|
||||
|
||||
export type MeterState = "FULL" | "WARNED" | "DEGRADED";
|
||||
|
||||
/**
|
||||
* Meter for a balance that is spent DOWN — a free grant, a prepaid pool. The
|
||||
* bar shows what is LEFT, so full reads as "plenty" and empty as "none", which
|
||||
* is how the sidebar footer's credits row reads and the only direction that
|
||||
* matches a figure quoting the remainder.
|
||||
*
|
||||
* The state bands still key on consumption, so the tone is unchanged: amber
|
||||
* once 80% is gone, red once it's exhausted. Meters for money SPENT against a
|
||||
* cap keep using {@link meterState} directly — there a full bar correctly means
|
||||
* "at your ceiling".
|
||||
*/
|
||||
export function remainingMeter(
|
||||
remaining: number,
|
||||
total: number,
|
||||
): { state: MeterState; pct: number } {
|
||||
const { state } = meterState(Math.max(0, total - remaining), total);
|
||||
const pct =
|
||||
total > 0
|
||||
? Math.min(100, Math.max(0, (Math.max(0, remaining) / total) * 100))
|
||||
: 0;
|
||||
return { state, pct };
|
||||
}
|
||||
|
||||
/** Warn (≥80%) / degrade (≥100%) band for a usage meter; mirrors the BE thresholds. */
|
||||
export function meterState(
|
||||
used: number,
|
||||
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
docCapForMoney,
|
||||
formatPeriodDate,
|
||||
meterState,
|
||||
remainingMeter,
|
||||
PREPAID_MONTHS_GRANTED,
|
||||
PREPAID_MONTHS_PAID,
|
||||
PDFS_PER_USER_MONTH,
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@app/auth/context";
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
||||
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
|
||||
|
||||
/**
|
||||
* Sidebar brand header for builds that ship the processor. When this user can
|
||||
* open it, the Stirling logo doubles as the editor⇄processor switcher: the mark
|
||||
* morphs into a chevron and opens the switch menu (the same BrandSwitcher the
|
||||
* processor sidebar uses). Users without access get a plain logo.
|
||||
*
|
||||
* The access gate lives in {@link useOtherAppSwitch} so this header and the
|
||||
* sidebar footer's "Open PDF Processor" row are driven by one answer.
|
||||
*/
|
||||
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
||||
const { portalAccess } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const otherApp = useOtherAppSwitch();
|
||||
|
||||
if (!portalAccess) {
|
||||
if (!otherApp) {
|
||||
return (
|
||||
<Logo
|
||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
||||
@@ -22,7 +28,7 @@ export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
||||
return (
|
||||
<BrandSwitcher
|
||||
current="editor"
|
||||
onSwitch={() => navigate(PORTAL_BASENAME)}
|
||||
onSwitch={otherApp.onOpen}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { isAuthRoute } from "@core/constants/routes";
|
||||
import { useCheckout } from "@app/contexts/CheckoutContext";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
import {
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
type ServerLicenseRequestPayload,
|
||||
@@ -300,7 +300,7 @@ const UpgradeBanner: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="warning-rounded"
|
||||
tone="warning"
|
||||
title={t(
|
||||
@@ -312,13 +312,6 @@ const UpgradeBanner: React.FC = () => {
|
||||
buttonIcon="info-rounded"
|
||||
onButtonClick={buttonText ? handleSeeInfo : undefined}
|
||||
dismissible={false}
|
||||
minHeight={60}
|
||||
background="#FFF4E6"
|
||||
borderColor="var(--mantine-color-orange-7)"
|
||||
textColor="#9A3412"
|
||||
iconColor="#EA580C"
|
||||
buttonVariant="filled"
|
||||
buttonColor="orange.7"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -341,7 +334,7 @@ const UpgradeBanner: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
{friendlyVisible && (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="stars-rounded"
|
||||
title={t("upgradeBanner.title", "Upgrade to Server Plan")}
|
||||
message={t(
|
||||
@@ -353,14 +346,7 @@ const UpgradeBanner: React.FC = () => {
|
||||
onButtonClick={handleUpgrade}
|
||||
onDismiss={handleFriendlyDismiss}
|
||||
show={friendlyVisible}
|
||||
background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
borderColor="transparent"
|
||||
textColor="#fff"
|
||||
iconColor="#fff"
|
||||
closeIconColor="#fff"
|
||||
buttonVariant="filled"
|
||||
buttonColor="blue"
|
||||
minHeight={48}
|
||||
tone="promo"
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
|
||||
+2
-9
@@ -12,7 +12,7 @@ import AvailablePlansSection from "@app/components/shared/config/configSections/
|
||||
import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection";
|
||||
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||
import {
|
||||
getPreferredCurrency,
|
||||
@@ -200,7 +200,7 @@ const AdminPlanSection: React.FC = () => {
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
{shouldShowLicenseWarning && (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="warning-rounded"
|
||||
tone="warning"
|
||||
title={t(
|
||||
@@ -215,13 +215,6 @@ const AdminPlanSection: React.FC = () => {
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={scrollToPlans}
|
||||
dismissible={false}
|
||||
minHeight={68}
|
||||
background="#FFF4E6"
|
||||
borderColor="var(--mantine-color-orange-7)"
|
||||
textColor="#9A3412"
|
||||
iconColor="#EA580C"
|
||||
buttonVariant="filled"
|
||||
buttonColor="orange.7"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@app/auth/context";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
|
||||
|
||||
/**
|
||||
* Self-hosted: the Spring session carries `portalAccess`, so the switch to the
|
||||
* processor is offered exactly when that flag is set.
|
||||
*/
|
||||
export function useOtherAppSwitch(): NavFooterAppLink | null {
|
||||
const { portalAccess } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
if (!portalAccess) return null;
|
||||
return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) };
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
||||
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
|
||||
import { usePortalAccess } from "@app/hooks/usePortalAccess";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
|
||||
/**
|
||||
* SaaS sidebar brand header. When the backend says this user can open the
|
||||
* processor (`/api/v1/auth/me` → `portalAccess` — the exact signal the
|
||||
* processor's own gate uses), the Stirling logo doubles as the
|
||||
* editor⇄processor switcher: the mark morphs into a chevron and opens the
|
||||
* switch menu (same BrandSwitcher the processor sidebar uses). Users without
|
||||
* access get a plain logo.
|
||||
*
|
||||
* Deliberately NOT gated on the editor's Supabase auth context: that context
|
||||
* never fetches /me, so it can't know about portal access (and its session
|
||||
* state doesn't always mirror the backend login that actually grants it).
|
||||
*/
|
||||
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
||||
const portalAccess = usePortalAccess();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!portalAccess) {
|
||||
return (
|
||||
<Logo
|
||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
||||
iconHeight="1.6rem"
|
||||
textHeight="1.3rem"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandSwitcher
|
||||
current="editor"
|
||||
onSwitch={() => navigate(PORTAL_BASENAME)}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Divider,
|
||||
Group,
|
||||
Image,
|
||||
@@ -11,10 +10,12 @@ import {
|
||||
TextInput,
|
||||
Modal,
|
||||
} from "@mantine/core";
|
||||
import { Avatar } from "@app/ui/Avatar";
|
||||
import { Button as DSButton } from "@app/ui/Button";
|
||||
import { FilePicker } from "@app/ui/FilePicker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
||||
import {
|
||||
isUserAnonymous,
|
||||
linkEmailIdentity,
|
||||
@@ -46,6 +47,8 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
refreshProfilePicture,
|
||||
refreshProfilePictureMetadata,
|
||||
} = useAuth();
|
||||
// Same name + initials the sidebar footer draws, so the two discs agree.
|
||||
const { displayName } = useAccountIdentity();
|
||||
|
||||
const PROFILE_BUCKET = "profile-pictures";
|
||||
|
||||
@@ -67,7 +70,6 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
const provider = profilePictureMetadata?.provider;
|
||||
|
||||
const profilePath = user ? `${user.id}/avatar` : null;
|
||||
const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || "U";
|
||||
|
||||
const handleProfileUpload = async (file: File | null) => {
|
||||
if (!file || !user || !profilePath) {
|
||||
@@ -410,12 +412,9 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
<Group align="center" gap="md">
|
||||
<Avatar
|
||||
src={profilePictureUrl || undefined}
|
||||
radius="xl"
|
||||
size={72}
|
||||
color="blue"
|
||||
>
|
||||
{profileInitial}
|
||||
</Avatar>
|
||||
name={displayName}
|
||||
size="xl"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -450,12 +449,9 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
<Group align="center" gap="md">
|
||||
<Avatar
|
||||
src={profilePictureUrl || undefined}
|
||||
radius="xl"
|
||||
size={72}
|
||||
color="blue"
|
||||
>
|
||||
{profileInitial}
|
||||
</Avatar>
|
||||
name={displayName}
|
||||
size="xl"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePortalAccess } from "@app/hooks/usePortalAccess";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
|
||||
|
||||
/**
|
||||
* SaaS: the editor's Supabase context never fetches /me, so processor access
|
||||
* comes from the backend via {@link usePortalAccess} — the same signal the
|
||||
* processor's own gate uses.
|
||||
*/
|
||||
export function useOtherAppSwitch(): NavFooterAppLink | null {
|
||||
const portalAccess = usePortalAccess();
|
||||
const navigate = useNavigate();
|
||||
if (!portalAccess) return null;
|
||||
return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) };
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { renderHook as baseRenderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const get = vi.fn();
|
||||
let currentUserId: string | null = null;
|
||||
@@ -20,10 +22,28 @@ function meReturning(portalAccess: boolean) {
|
||||
return { data: { user: { portalAccess } } };
|
||||
}
|
||||
|
||||
// A fresh client per render, so one test's cached answer can't satisfy the
|
||||
// next — each case exercises a cold cache unless it deliberately shares one.
|
||||
let client: QueryClient;
|
||||
|
||||
function renderHook<T>(cb: () => T) {
|
||||
return baseRenderHook(cb, {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe("usePortalAccess", () => {
|
||||
beforeEach(() => {
|
||||
// The hook now remembers the last answer across mounts, so without this a
|
||||
// prior test's result seeds the next one.
|
||||
localStorage.clear();
|
||||
get.mockReset();
|
||||
currentUserId = null;
|
||||
client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the backend's answer for the signed-in user", async () => {
|
||||
@@ -82,12 +102,31 @@ describe("usePortalAccess", () => {
|
||||
expect(first.result.current).toBe(false);
|
||||
first.unmount();
|
||||
|
||||
// The failure isn't sticky.
|
||||
// The failure isn't sticky — a cold cache asks again.
|
||||
client.clear();
|
||||
get.mockResolvedValue(meReturning(true));
|
||||
const second = renderHook(() => usePortalAccess());
|
||||
await waitFor(() => expect(second.result.current).toBe(true));
|
||||
});
|
||||
|
||||
it("shows the last known answer at first paint, then revalidates", async () => {
|
||||
// What stops the switcher and the footer's "Open ..." row popping in a
|
||||
// request late on every mount.
|
||||
currentUserId = "admin-1";
|
||||
get.mockResolvedValue(meReturning(true));
|
||||
const first = renderHook(() => usePortalAccess());
|
||||
await waitFor(() => expect(first.result.current).toBe(true));
|
||||
first.unmount();
|
||||
|
||||
client.clear();
|
||||
get.mockResolvedValue(meReturning(false));
|
||||
const second = renderHook(() => usePortalAccess());
|
||||
// Seeded from the remembered answer before the request lands...
|
||||
expect(second.result.current).toBe(true);
|
||||
// ...and corrected once the backend disagrees.
|
||||
await waitFor(() => expect(second.result.current).toBe(false));
|
||||
});
|
||||
|
||||
it("ignores a response that lands after unmount", async () => {
|
||||
currentUserId = "admin-1";
|
||||
let resolveMe: (v: unknown) => void = () => {};
|
||||
|
||||
@@ -1,52 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import {
|
||||
readCachedOtherApp,
|
||||
writeCachedOtherApp,
|
||||
} from "@app/services/navFooterCache";
|
||||
import { qk } from "@app/query/keys";
|
||||
|
||||
async function fetchPortalAccess(): Promise<boolean> {
|
||||
const res = await apiClient.get<{ user?: { portalAccess?: boolean } }>(
|
||||
"/api/v1/auth/me",
|
||||
);
|
||||
return res.data.user?.portalAccess === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current user can open the processor (admin portal), straight
|
||||
* from the backend (`/api/v1/auth/me` → `portalAccess`) — the same signal the
|
||||
* processor's own SaasPortalGate uses. Components that must mirror processor
|
||||
* access (e.g. the sidebar's editor⇄processor switcher) ask here.
|
||||
* access (the sidebar's editor⇄processor switcher and its footer row) ask here.
|
||||
*
|
||||
* The editor's Supabase auth context can't *answer* this — it never fetches
|
||||
* /me — so it is used only to identify who is asking. Keying the effect on
|
||||
* that identity is what keeps the answer per-user: the SPA can swap users
|
||||
* /me — so it is used only to identify who is asking. That identity is the
|
||||
* cache key, which is what keeps the answer per-user: the SPA can swap users
|
||||
* without a reload (Supabase fires SIGNED_OUT/SIGNED_IN in place; only the
|
||||
* settings Logout button hard-navigates), so any answer held beyond the
|
||||
* current identity would leak to whoever signs in next.
|
||||
* settings Logout button hard-navigates), and a keyed cache addresses each
|
||||
* identity separately rather than holding one answer that would have to be
|
||||
* invalidated on the swap — the bug class this hook once had.
|
||||
*
|
||||
* Deliberately unmemoised beyond the mount: the one consumer (the sidebar
|
||||
* switcher) mounts once, so a cross-mount cache would only add user-scoped
|
||||
* state that has to be invalidated on identity change — the bug class this
|
||||
* hook already had once. Guests skip the request entirely.
|
||||
* Cached through the app query client, so leaving the editor for the processor
|
||||
* and coming back resolves from cache: the switcher is there on first paint
|
||||
* instead of appearing a request later. Guests skip the request entirely.
|
||||
*/
|
||||
export function usePortalAccess(): boolean {
|
||||
const { user } = useAuth();
|
||||
const userId = user?.id ?? null;
|
||||
const [access, setAccess] = useState(false);
|
||||
// The query cache is per-tree and per-load, so it can't help a cold start or
|
||||
// the hop into the processor, which mounts its own client. Seed from the last
|
||||
// answer this browser saw so the switcher and the footer's "Open ..." row are
|
||||
// there at first paint. Marked ancient so it still revalidates immediately.
|
||||
const [seed] = useState(readCachedOtherApp);
|
||||
|
||||
const { data, isSuccess } = useQuery({
|
||||
queryKey: qk.portalAccess(userId),
|
||||
queryFn: fetchPortalAccess,
|
||||
// Signed out: nothing to ask, and any previous answer is void.
|
||||
enabled: userId !== null,
|
||||
// Backend unreachable or guest (401) means no access now; a later refetch
|
||||
// asks again rather than trusting the failure.
|
||||
retry: false,
|
||||
initialData: seed,
|
||||
initialDataUpdatedAt: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Signed out: nothing to ask, and any previous answer is void.
|
||||
if (userId === null) {
|
||||
setAccess(false);
|
||||
return;
|
||||
}
|
||||
// Only a real answer is recorded — a failed probe is not one, so the next
|
||||
// mount trusts the last backend response rather than a network blip.
|
||||
if (isSuccess && data !== undefined) writeCachedOtherApp(data);
|
||||
}, [isSuccess, data]);
|
||||
|
||||
let cancelled = false;
|
||||
apiClient
|
||||
.get<{ user?: { portalAccess?: boolean } }>("/api/v1/auth/me")
|
||||
.then((res) => {
|
||||
if (!cancelled) setAccess(res.data.user?.portalAccess === true);
|
||||
})
|
||||
.catch(() => {
|
||||
// Backend unreachable or guest (401): no access now; a remount or
|
||||
// identity change asks again rather than trusting a failure.
|
||||
if (!cancelled) setAccess(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
return access;
|
||||
return data === true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
const get = vi.fn();
|
||||
vi.mock("@app/services/apiClient", () => ({
|
||||
default: { get: (...args: unknown[]) => get(...args) },
|
||||
}));
|
||||
vi.mock("@app/hooks/walletDevPreview", () => ({
|
||||
getWalletDevPreview: () => null,
|
||||
}));
|
||||
vi.mock("@app/services/billing", () => ({ createPortalSession: vi.fn() }));
|
||||
vi.mock("@app/platform/openExternal", () => ({ openExternal: vi.fn() }));
|
||||
|
||||
const { useWallet } = await import("@app/hooks/useWallet");
|
||||
|
||||
/** Full enough for the hook's deep-compare, which reads every field. */
|
||||
function walletWith(freeRemaining: number) {
|
||||
return {
|
||||
data: {
|
||||
teamId: 1,
|
||||
status: "free",
|
||||
role: "leader",
|
||||
billingPeriodStart: "2026-08-01",
|
||||
billingPeriodEnd: "2026-08-31",
|
||||
billableUsed: 500 - freeRemaining,
|
||||
billableLimit: 500,
|
||||
freeAllowance: 500,
|
||||
freeRemaining,
|
||||
pricePerDocMinor: 2,
|
||||
bundleRatePerCreditMinor: null,
|
||||
currency: "usd",
|
||||
estimatedBillMinor: 0,
|
||||
capUsd: null,
|
||||
noCap: false,
|
||||
stripeSubscriptionId: null,
|
||||
spendUnitsThisPeriod: 0,
|
||||
docsProcessedThisPeriod: 0,
|
||||
uniquePdfsThisPeriod: 0,
|
||||
sizeMultiplierPdfsThisPeriod: 0,
|
||||
billingMode: "metered",
|
||||
prepaidUnitsRemaining: 0,
|
||||
prepaidUnitsTotal: 0,
|
||||
prepaidExpiresAt: null,
|
||||
recent: [],
|
||||
members: [],
|
||||
categoryBreakdown: { api: 0, ai: 0, automation: 0 },
|
||||
categoryDocs: { api: 0, ai: 0, automation: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useWallet — keeping the figures fresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
get.mockReset();
|
||||
get.mockResolvedValue(walletWith(500));
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("re-reads the wallet on the poll interval", async () => {
|
||||
const { result } = renderHook(() => useWallet());
|
||||
await waitFor(() => expect(result.current.wallet).not.toBeNull());
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
|
||||
get.mockResolvedValue(walletWith(480));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(480));
|
||||
});
|
||||
|
||||
it("polls silently, so consumers gating on loading/error don't flicker", async () => {
|
||||
const { result } = renderHook(() => useWallet());
|
||||
await waitFor(() => expect(result.current.wallet).not.toBeNull());
|
||||
|
||||
// A poll that fails must leave the last good snapshot, and must not raise
|
||||
// `error` — Plan swaps a working page for an alert on that.
|
||||
get.mockRejectedValue(new Error("network blip"));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.wallet?.freeRemaining).toBe(500);
|
||||
});
|
||||
|
||||
it("settles loading when a silent poll supersedes an in-flight visible load", async () => {
|
||||
// The mount load raises `loading`; a poll firing before it lands cancels it.
|
||||
// If clearing the flag were the silent load's to skip, both would decline
|
||||
// and `loading` would stay true forever — which permanently suppresses the
|
||||
// limit modals, since they do `if (loading || !wallet) return null`.
|
||||
const visibility = vi.spyOn(document, "visibilityState", "get");
|
||||
visibility.mockReturnValue("visible");
|
||||
|
||||
let landMount: (v: unknown) => void = () => {};
|
||||
get.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
landMount = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useWallet());
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
get.mockResolvedValue(walletWith(470));
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await act(async () => {
|
||||
landMount(walletWith(500));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(470));
|
||||
expect(result.current.loading).toBe(false);
|
||||
visibility.mockRestore();
|
||||
});
|
||||
|
||||
it("clears a stale error once a silent poll succeeds", async () => {
|
||||
// The visible mount load failing is meant to be logged; only the silent
|
||||
// retries stay quiet.
|
||||
expectConsole.warn(/\[useWallet\] fetch failed/);
|
||||
get.mockRejectedValueOnce(new Error("network blip"));
|
||||
const { result } = renderHook(() => useWallet());
|
||||
await waitFor(() => expect(result.current.error).not.toBeNull());
|
||||
|
||||
get.mockResolvedValue(walletWith(500));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeNull());
|
||||
expect(result.current.wallet?.freeRemaining).toBe(500);
|
||||
});
|
||||
|
||||
it("stops polling while the tab is hidden and re-reads on return", async () => {
|
||||
const visibility = vi.spyOn(document, "visibilityState", "get");
|
||||
visibility.mockReturnValue("visible");
|
||||
const { result } = renderHook(() => useWallet());
|
||||
await waitFor(() => expect(result.current.wallet).not.toBeNull());
|
||||
const afterMount = get.mock.calls.length;
|
||||
|
||||
visibility.mockReturnValue("hidden");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
vi.advanceTimersByTime(120_000);
|
||||
});
|
||||
expect(get).toHaveBeenCalledTimes(afterMount);
|
||||
|
||||
visibility.mockReturnValue("visible");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await waitFor(() => expect(get.mock.calls.length).toBe(afterMount + 1));
|
||||
visibility.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user