diff --git a/.env.example b/.env.example index c4d78b9..985238f 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,15 @@ # Prego Environment Variables # Copy this file to .env and fill in values -# Stripe (required for payment features) -STRIPE_SECRET_KEY=sk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... - # App config APP_URL=https://prego.ashbi.ca PORT=3000 # Deployment COOLIFY_TOKEN= + +# Public native SDK keys only; never put RevenueCat secret API keys here. +# Keep purchases disabled until products, clinical review and store QA pass. +VITE_PURCHASES_ENABLED=false +VITE_REVENUECAT_IOS_KEY= +VITE_REVENUECAT_ANDROID_KEY= diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index c114878..8ae43ab 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -2,12 +2,24 @@ name: Auto Merge on: pull_request_target: types: [labeled, unlabeled, synchronize, opened, edited, reopened] + +concurrency: + group: auto-merge-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: auto-merge: - if: contains(github.event.pull_request.labels.*.name, 'auto-merge') - runs-on: ubuntu-latest + if: >- + contains(github.event.pull_request.labels.*.name, 'auto-merge') && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + checks: read steps: - - uses: pascalgn/automerge-action@v0.16.3 + - uses: pascalgn/automerge-action@d1203c0bf94a827b991e5de69d662e9163304fa0 # v0.16.3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} MERGE_LABELS: "auto-merge" diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 64bed9d..dfd02e1 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -18,6 +18,13 @@ on: tags: ['v*'] pull_request: branches: [main, master] + workflow_dispatch: + inputs: + publish: + description: 'Publish only after explicit release approval' + type: boolean + required: false + default: false workflow_call: inputs: image_name: @@ -84,6 +91,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log in to GHCR + if: github.event_name == 'workflow_dispatch' && inputs.publish uses: docker/login-action@v3 with: registry: ghcr.io @@ -105,7 +113,7 @@ jobs: context: . file: ${{ inputs.dockerfile || 'Dockerfile' }} platforms: ${{ inputs.platforms || 'linux/amd64,linux/arm64' }} - push: true + push: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} tags: | ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.tag }} ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.tag }}-${{ steps.meta.outputs.sha }} @@ -121,6 +129,7 @@ jobs: org.opencontainers.image.created=${{ github.event.repository.updated_at }} - name: Image summary + if: github.event_name == 'workflow_dispatch' && inputs.publish run: | echo "## Image published" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index c0cefcb..b0f77e1 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -19,14 +19,17 @@ jobs: node-version: '22' cache: 'npm' - - name: Setup Java 17 - uses: actions/setup-java@v4 + - name: Setup Java 21 + uses: actions/setup-java@v6 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools platforms;android-36 build-tools;35.0.0 + log-accepted-android-sdk-licenses: false - name: Install dependencies run: npm ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24f4020..4d04048 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,63 +1,36 @@ -name: CI Build - -# Build-only workflow: verifies the project builds cleanly. No deploy, no secrets required. - +name: Web validation on: push: branches: [master] pull_request: branches: [master] workflow_dispatch: - permissions: contents: read - concurrency: - group: ci-build-${{ github.ref }} + group: web-${{ github.ref }} cancel-in-progress: true - jobs: - build: - name: Build + validate: runs-on: ubuntu-latest - timeout-minutes: 15 - + timeout-minutes: 20 steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Type check (if configured) - run: | - if grep -q '"typecheck"\|"type-check"\|"tsc"' package.json; then - echo "Running typecheck..." - npm run typecheck || npm run type-check || npx tsc --noEmit - else - echo "No typecheck script found, skipping." - fi - continue-on-error: true - - - name: Lint (if configured) - run: | - if grep -q '"lint"' package.json; then - echo "Running lint..." - npm run lint - else - echo "No lint script found, skipping." - fi - continue-on-error: true - - - name: Build - run: npm run build - env: - # CI-safe defaults so the build doesn't depend on real env vars - NODE_ENV: production - CI: true + node-version: '22' + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: node --test scripts/test/*.test.mjs + - run: python3 scripts/test/play-upload.test.py + - run: npm run build + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: browser-failure-evidence + path: test-results/ diff --git a/.github/workflows/internal-testing.yml b/.github/workflows/internal-testing.yml new file mode 100644 index 0000000..0e8d9a3 --- /dev/null +++ b/.github/workflows/internal-testing.yml @@ -0,0 +1,154 @@ +name: Internal testing distribution +on: + workflow_call: + inputs: + platform: + type: string + required: true + version: + type: string + required: true + build_number: + type: string + required: true +permissions: + contents: read +concurrency: + group: internal-testing + cancel-in-progress: false +jobs: + testflight: + if: inputs.platform == 'ios' || inputs.platform == 'both' + runs-on: macos-15 + timeout-minutes: 45 + env: + PREGO_VERSION_NAME: ${{ inputs.version }} + PREGO_BUILD_NUMBER: ${{ inputs.build_number }} + VITE_PURCHASES_ENABLED: 'false' + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID || 'NVJ5UY3T2X' }} + APPLE_APP_ID: ${{ vars.APPLE_APP_ID }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_PRIVATE_KEY: ${{ secrets.ASC_API_PRIVATE_KEY || secrets.ASC_PRIVATE_KEY }} + IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_P12_BASE64 || secrets.IOS_CERTIFICATE_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_P12_PASSWORD || secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROFILE_BASE64 || secrets.IOS_PROFILE_BASE64 }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - name: Check TestFlight configuration + run: node scripts/check-beta-config.mjs ios + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - run: npm ci + - run: npm run build + - run: npx cap sync ios + - name: Prepare temporary signing credentials + run: scripts/prepare-ios-signing.sh + - name: Archive signed iOS candidate + run: | + xcodebuild -project ios/App/App.xcodeproj -scheme App -configuration Release -destination 'generic/platform=iOS' -archivePath "$RUNNER_TEMP/Prego.xcarchive" DEVELOPMENT_TEAM="$APPLE_TEAM_ID" CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' PROVISIONING_PROFILE_SPECIFIER="$(cat "$RUNNER_TEMP/prego-signing/profile-uuid")" MARKETING_VERSION="$PREGO_VERSION_NAME" CURRENT_PROJECT_VERSION="$PREGO_BUILD_NUMBER" archive + - name: Export App Store IPA + run: xcodebuild -exportArchive -archivePath "$RUNNER_TEMP/Prego.xcarchive" -exportPath "$RUNNER_TEMP/prego-ipa" -exportOptionsPlist "$RUNNER_TEMP/prego-signing/ExportOptions.plist" + - name: Preserve signed build and checksum + run: shasum -a 256 "$RUNNER_TEMP/prego-ipa/"*.ipa > "$RUNNER_TEMP/prego-ipa/SHA256SUMS.txt" + - uses: actions/upload-artifact@v4 + with: + name: testflight-${{ inputs.version }}-${{ inputs.build_number }} + path: ${{ runner.temp }}/prego-ipa/ + retention-days: 14 + - name: Upload to App Store Connect for TestFlight processing + env: + API_PRIVATE_KEYS_DIR: ${{ runner.temp }}/prego-signing + run: | + IPA_PATH=$(find "$RUNNER_TEMP/prego-ipa" -maxdepth 1 -name '*.ipa' -print -quit) + xcrun altool --validate-app -f "$IPA_PATH" -t ios --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" + xcrun altool --upload-app -f "$IPA_PATH" -t ios --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" + echo 'Upload accepted. Confirm processing, export compliance and internal tester access in App Store Connect.' >> "$GITHUB_STEP_SUMMARY" + - name: Remove signing credentials + if: always() + run: | + if [ -f "$RUNNER_TEMP/prego-signing/profile-uuid" ]; then + PROFILE_UUID=$(cat "$RUNNER_TEMP/prego-signing/profile-uuid") + rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision" + fi + security delete-keychain "$RUNNER_TEMP/prego-signing/build.keychain-db" || true + rm -rf "$RUNNER_TEMP/prego-signing" + play-internal: + if: inputs.platform == 'android' || inputs.platform == 'both' + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + PREGO_VERSION_NAME: ${{ inputs.version }} + PREGO_BUILD_NUMBER: ${{ inputs.build_number }} + VITE_PURCHASES_ENABLED: 'false' + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 || secrets.ANDROID_KEYSTORE_BASE64 }} + PREGO_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + PREGO_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + PREGO_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - name: Check Google Play configuration + run: node scripts/check-beta-config.mjs android + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - uses: android-actions/setup-android@v3 + with: + packages: platform-tools platforms;android-36 build-tools;35.0.0 + log-accepted-android-sdk-licenses: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: npm ci + - run: npm run build + - run: npx cap sync android + - name: Prepare temporary upload credentials + run: | + umask 077 + python3 - <<'PY' + import base64, os + from pathlib import Path + root = Path(os.environ['RUNNER_TEMP']) + root.joinpath('prego-upload.jks').write_bytes(base64.b64decode(os.environ['ANDROID_KEYSTORE_BASE64'], validate=True)) + root.joinpath('play-service-account.json').write_text(os.environ['PLAY_SERVICE_ACCOUNT_JSON']) + PY + - name: Build signed internal testing bundle + env: + PREGO_KEYSTORE_PATH: ${{ runner.temp }}/prego-upload.jks + run: ./gradlew bundleRelease lintRelease + working-directory: android + - name: Verify bundle signature and checksum + run: | + jarsigner -verify android/app/build/outputs/bundle/release/app-release.aab + python3 - <<'PY' + import zipfile + with zipfile.ZipFile('android/app/build/outputs/bundle/release/app-release.aab') as bundle: + names = bundle.namelist() + if not any(name.startswith('META-INF/') and name.endswith(('.RSA', '.EC', '.DSA')) for name in names): + raise SystemExit('Bundle is unsigned; refusing upload.') + PY + sha256sum android/app/build/outputs/bundle/release/app-release.aab > android/app/build/outputs/bundle/release/SHA256SUMS.txt + - uses: actions/upload-artifact@v4 + with: + name: play-internal-${{ inputs.version }}-${{ inputs.build_number }} + path: android/app/build/outputs/bundle/release/ + retention-days: 14 + - name: Upload only to Google Play internal testing + run: | + python3 -m pip install 'google-auth==2.40.3' 'requests==2.32.5' + python3 scripts/upload-play-internal.py --credentials "$RUNNER_TEMP/play-service-account.json" --bundle android/app/build/outputs/bundle/release/app-release.aab --build-number "$PREGO_BUILD_NUMBER" --version "$PREGO_VERSION_NAME" + echo 'Internal-track edit committed. Confirm Play processing, tester list and opt-in access in Play Console.' >> "$GITHUB_STEP_SUMMARY" + - name: Remove upload credentials + if: always() + run: rm -f "$RUNNER_TEMP/prego-upload.jks" "$RUNNER_TEMP/play-service-account.json" diff --git a/.github/workflows/native-validation.yml b/.github/workflows/native-validation.yml new file mode 100644 index 0000000..8d4d09e --- /dev/null +++ b/.github/workflows/native-validation.yml @@ -0,0 +1,145 @@ +name: Native validation +on: + pull_request: + branches: [master] + push: + branches: [master] + workflow_dispatch: + inputs: + publish_internal: + description: Upload the verified usability beta to the selected internal testing stores + type: boolean + default: false + platform: + type: choice + options: [both, ios, android] + default: both + version: + description: Marketing version (major.minor.patch) + type: string + default: '1.0.0' + build_number: + description: Build number higher than any previous Apple/Google build + type: string + default: '2' +permissions: + contents: read +concurrency: + group: native-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.publish_internal && 'distribution' || 'validation' }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' || !inputs.publish_internal }} +jobs: + beta-config: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Verify internal distribution settings before building + if: github.event_name == 'workflow_dispatch' && inputs.publish_internal + env: + BETA_PLATFORM: ${{ inputs.platform }} + PREGO_VERSION_NAME: ${{ inputs.version }} + PREGO_BUILD_NUMBER: ${{ inputs.build_number }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID || 'NVJ5UY3T2X' }} + APPLE_APP_ID: ${{ vars.APPLE_APP_ID }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_PRIVATE_KEY: ${{ secrets.ASC_API_PRIVATE_KEY || secrets.ASC_PRIVATE_KEY }} + IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_P12_BASE64 || secrets.IOS_CERTIFICATE_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_P12_PASSWORD || secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROFILE_BASE64 || secrets.IOS_PROFILE_BASE64 }} + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 || secrets.ANDROID_KEYSTORE_BASE64 }} + PREGO_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + PREGO_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + PREGO_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + run: | + RESULT=0 + if [ "$BETA_PLATFORM" = 'ios' ] || [ "$BETA_PLATFORM" = 'both' ]; then + node scripts/check-beta-config.mjs ios || RESULT=1 + fi + if [ "$BETA_PLATFORM" = 'android' ] || [ "$BETA_PLATFORM" = 'both' ]; then + node scripts/check-beta-config.mjs android || RESULT=1 + fi + exit "$RESULT" + beta-web: + needs: beta-config + if: github.event_name == 'workflow_dispatch' && inputs.publish_internal + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test + - run: node --test scripts/test/*.test.mjs + - run: python3 scripts/test/play-upload.test.py + - run: npm run build + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + android: + needs: beta-config + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - uses: android-actions/setup-android@v3 + with: + packages: platform-tools platforms;android-36 build-tools;35.0.0 + log-accepted-android-sdk-licenses: false + - run: npm ci + - run: npm run build + - run: npx cap sync android + - run: ./gradlew assembleDebug bundleRelease lintDebug + working-directory: android + - uses: actions/upload-artifact@v4 + with: + name: android-validation-${{ github.sha }} + path: | + android/app/build/outputs/apk/debug/*.apk + android/app/build/outputs/bundle/release/*.aab + ios: + needs: beta-config + runs-on: macos-15 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npm run build + - run: npx cap sync ios + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - run: xcodebuild -version + - name: Build iOS simulator candidate + run: xcodebuild -project ios/App/App.xcodeproj -scheme App -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath ios/build CODE_SIGNING_ALLOWED=NO build + - uses: actions/upload-artifact@v4 + with: + name: ios-simulator-${{ github.sha }} + path: ios/build/Build/Products/Debug-iphonesimulator/*.app + internal-distribution: + if: github.event_name == 'workflow_dispatch' && inputs.publish_internal + needs: [android, ios, beta-web] + uses: ./.github/workflows/internal-testing.yml + with: + platform: ${{ inputs.platform }} + version: ${{ inputs.version }} + build_number: ${{ inputs.build_number }} + secrets: inherit diff --git a/.gitignore b/.gitignore index 9cce634..0a37442 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,16 @@ dist-ssr *.sw? .claude/ + +# Native/browser validation output +playwright-report/ +test-results/ +ios/build/ +android/local.properties +*.p12 +*.p8 +*.mobileprovision +*.jks +*.keystore +play-service-account*.json +__pycache__/ diff --git a/CAPABILITY-MAP.md b/CAPABILITY-MAP.md new file mode 100644 index 0000000..017fce4 --- /dev/null +++ b/CAPABILITY-MAP.md @@ -0,0 +1,69 @@ +# Prego v1 — Proposed capability map + +Status: working scope; user directed implementation on 2026-10-11. External publication remains controlled. +Prepared: 2026-10-11. Source: user-provided October 2026 product blueprint. + +## Product boundaries + +Pregnancy fitness education for adults within a professionally reviewed intended population. Retain React, TypeScript and Capacitor. Five destinations: Today, Move, Learn, Progress and You. Free useful core plus a proposed one-time Pregnancy Pass; price remains a hypothesis. + +Postpartum programmes, AI advice, social features, meal planning and medical tracking are outside v1. The app does not grant medical clearance. Passing a due date must not infer pregnancy outcome. + +## Capabilities + +Dependencies refer to provider contracts, not a requirement to finish every provider screen before prototyping consumers. + +| Stable module id | Responsibility | Depends on | +| --- | --- | --- | +| clinical-governance | Intended population, screening/referral policy, warning guidance, review ownership and approved rule contracts | — | +| personal-data | Versioned persistence, migration, export, restore, deletion and recovery | — | +| pregnancy-stage | Date-derived gestational stage, date edits and explicit unknown/out-of-range states | clinical-governance | +| approved-content | Exercise/article registry, reviewed modifications, media, approval history and withdrawal/version policy | clinical-governance | +| workout-selection | Deterministic eligibility, filters and substitutions; unknown inputs never imply eligibility | pregnancy-stage, approved-content | +| workout-player | Guided sessions, interruption-safe pause/resume, offline assets and idempotent completion records | personal-data, workout-selection | +| app-experience | Accessible navigation, onboarding, Today, Move, Learn, Progress and You; preferences and opt-in reminders | personal-data, pregnancy-stage, approved-content, workout-player | +| native-billing | iOS/Android integration, verified purchases, restoration and entitlement lifecycle | — | +| release-validation | Reproducible checks/builds, privacy verification, device QA, beta evidence and release operations | app-experience, native-billing | + +Build order: establish clinical-governance and personal-data contracts; define pregnancy-stage and approved-content; implement workout-selection; implement workout-player; integrate app-experience; complete release-validation. Native-billing can proceed independently once native platform requirements are established. Restore CI validation early and keep it running throughout this sequence. + +Each module specification should define its inputs, outputs, validation, error behaviour, compatibility, owner and evidence required for acceptance. Clinical rules and screening copy require qualified review; developers must not invent them. + +## Existing GitHub work to preserve + +Repository metadata and issues inspected via GitHub on 2026-10-11; this is not a source-code, clinical or CI audit. + +| Thread | Verified status / proposed use | +| --- | --- | +| [#41](https://github.com/camster91/prego/issues/41) | Open parent shipping epic; retain as the backlog anchor. | +| [#18](https://github.com/camster91/prego/issues/18) | Open data recovery issue; expand to cover versioned native/web persistence, complete deletion and restoration. A shadow copy in the same storage is not protection against clearing all storage. | +| [#42](https://github.com/camster91/prego/issues/42) | Open validation issue; refresh its August commit/workflow evidence before claiming current CI failure or missing protection. | +| [#13](https://github.com/camster91/prego/issues/13) | Open dependency-refresh issue; reconcile against the current lockfile and pending upgrade PRs before choosing changes. | +| [#31](https://github.com/camster91/prego/issues/31) / [PR #53](https://github.com/camster91/prego/pull/53) | Licensing decision remains open; an open MIT-license PR already exists. Do not duplicate or infer a licensing decision. | +| [PR #61](https://github.com/camster91/prego/pull/61) | Open README update; coordinate future documentation with it. | + +Default branch is master. Its package.json declares React 19, TypeScript 5.9, Capacitor 8, Zustand 5 and Vitest 4; includes Android and Stripe dependencies. This does not establish installed versions or native/billing correctness. Available scripts include npm run lint, npm test and npm run build. + +## Proposed backlog priorities + +1. Establish intended-use boundaries, reviewer ownership, evidence registry and content approval process. +2. Refresh #42 against the actual master head and restore reproducible mandatory validation. +3. Expand #18 with concrete export/restore/deletion/migration acceptance cases; document limits of device-local recovery. +4. Specify pregnancy-stage behaviour and boundary/date regression cases; do not hard-code clinical policy without review. +5. Specify deterministic eligibility and interruption-safe workout state. +6. Define accessible user flows and approved-content production requirements. +7. Add native iOS and verify Android/store billing, offline entitlements and restore behaviour. +8. Complete device/privacy/accessibility QA, beta instrumentation and controlled release evidence. + +Before publishing child issues, reconcile each against existing issues and PRs. Each issue needs scope, dependencies, acceptance criteria, verification, affected platforms and evidence to close. Targets in the blueprint are proposed targets, not completed work or verified market/regulatory facts. + +## Scope decisions for the PRD + +- Confirm whether the next deliverable is requirements/backlog, blueprint review or implementation. +- Confirm launch regions/languages and supported device/OS baseline. +- Assign product, clinical review, content and support owners. +- Define the free library and paid-content boundary, including offline entitlement behaviour. +- Select measured startup/workout-start budgets and the exact beta metric denominators. +- Revalidate store, naming, regulatory and permissions claims against authoritative sources before release. + +Implementation is underway under the user’s instruction. See docs/PRODUCT-REQUIREMENTS.md and docs/RELEASE-READINESS.md for current scope and evidence. This map indexes module responsibilities. diff --git a/CLAUDE.md b/CLAUDE.md index 4e543d3..68bb6f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,15 +1,15 @@ # CLAUDE.md — Prego -Prego is a mobile-first React web app for pregnancy-safe exercises. It provides guided workout plans, an exercise library, progress tracking, and achievement badges across all three trimesters. +Prego is a React/Capacitor pregnancy movement companion with Today, Move, Learn, Progress and You. Exercise content is draft: never label it approved or enable guided workouts without qualified review and an approved screening/eligibility contract. See docs/PRODUCT-REQUIREMENTS.md and docs/RELEASE-READINESS.md. **Production URL**: https://prego.ashbi.ca ## Tech Stack - **Frontend**: React 19, TypeScript 5.9, Vite 7, Tailwind CSS v4 -- **State**: Zustand 5 (persisted to localStorage), TanStack React Query v5 +- **State**: Zustand 5 (versioned browser storage or encrypted native storage), TanStack React Query v5 - **Routing**: React Router v7 with lazy-loaded pages -- **Backend**: Express 4 (serves SPA + Stripe API endpoints) +- **Backend**: Express 4 (serves SPA + health check; legacy billing endpoints return 410) - **Mobile**: Capacitor 8 (iOS/Android) - **Testing**: Vitest 4 + Testing Library + jsdom - **Deployment**: GitHub Actions → Coolify (webhook) @@ -43,7 +43,7 @@ src/ ├── stores/ # Zustand stores (userStore, workoutSessionStore) ├── test/ # Test setup and specs └── types/ # TypeScript type definitions (exercise, user, workout, progress, timer) -server/ # Express backend (Stripe webhooks, checkout, health check) +server/ # Express static server + health check ``` ## Path Alias @@ -55,7 +55,7 @@ server/ # Express backend (Stripe webhooks, checkout, health check ### Error Handling - Global `ErrorBoundary` wraps the app in `App.tsx` — catches unhandled React errors - Individual pages handle "not found" states (exercise, workout) with fallback UI -- Server gracefully handles missing Stripe configuration (warns on startup, returns 503) +- Server has no web checkout or unauthenticated customer portal; native billing is disabled in the usability beta ### Components - Functional components with hooks only — no class components (ErrorBoundary is the sole exception) @@ -70,7 +70,7 @@ server/ # Express backend (Stripe webhooks, checkout, health check - Use `cn()` from `@/shared/utils/cn` to merge classnames ### State Management -- Zustand stores with `persist` middleware (localStorage) +- Zustand stores with `persist` middleware using src/lib/storage.ts; native keychain/encrypted storage, web localStorage; preserve migrations and complete deletion - localStorage keys: `prego-user-storage`, `prego-workout-session-storage` - Memoized selector functions for derived state (e.g. `selectCompletedWorkoutsCount`) @@ -99,25 +99,21 @@ server/ # Express backend (Stripe webhooks, checkout, health check ## Backend API Express server (`server/index.js`): -- `POST /api/webhooks/stripe` — Stripe webhook handler -- `POST /api/create-checkout-session` — Stripe subscription checkout -- `POST /api/create-portal-session` — Stripe customer portal +- Legacy Stripe POST routes return HTTP 410; no Stripe SDK or web billing runs - `GET /api/health` — Health check - Serves built SPA from `dist/` with fallback routing ## Environment Variables -- `STRIPE_SECRET_KEY` — Stripe API key (backend) -- `STRIPE_WEBHOOK_SECRET` — Stripe webhook signature validation - `COOLIFY_TOKEN` — Deployment webhook authorization - `APP_URL` — Production domain (default: https://prego.ashbi.ca) - `PORT` — Server port (default: 3000) ## Domain-Specific Notes -- **Pregnancy safety is critical**: exercises have trimester-appropriate filtering, contraindications, and modifications -- Trimester calculation uses due date via `calculatePregnancyInfo()` in user store +- **Pregnancy safety is critical**: legacy draft trimester tags are not clinical approval; src/lib/contentReview.ts fails closed until review and screening rules are supplied +- Stage derives from calendar dates via calculatePregnancyInfo()/usePregnancyStage(); never use stale profile week fields for selection. Explicit unknown/out-of-range/date-passed states never grant eligibility. - Audio cues use Web Audio API directly (not the use-sound library) in ActiveWorkoutPage - PWA-capable: manifest.json, service worker (`public/sw.js`), registered in `main.tsx` -- Service worker uses network-first for navigation, cache-first for static assets +- Production service worker is generated from the complete immutable asset bundle by scripts/generate-service-worker.mjs; native builds do not register it - Icon-only buttons must include `aria-label` for accessibility diff --git a/Dockerfile b/Dockerfile index 4cfbada..e233407 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM node:20-alpine AS builder +FROM node:22-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build -FROM node:20-alpine +FROM node:22-alpine WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1c7a684 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cameron Ashley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6d50c5a..9151069 100644 --- a/README.md +++ b/README.md @@ -1,221 +1,60 @@ # Prego -**Pregnancy Exercise App** +Pregnancy movement education and personal workout records, built with React, TypeScript and Capacitor for web, iOS and Android. -A modern, mobile-friendly application delivering pregnancy-safe exercises, guided workout plans, real-time timers, and progress tracking. Built with Capacitor for native iOS and Android deployment. +**Current status: engineering candidate, not approved for public/store release.** Guided workouts are blocked until qualified content and screening review. Native store purchases are disabled by default. No checkbox establishes medical clearance, and a checkout URL cannot grant an entitlement. -## Features +## Experience -### Personalized Experience -- **Automatic Trimester Tracking:** Calculates current trimester based on due date -- **Safe Exercise Library:** 12+ pregnancy-safe exercises vetted for each trimester -- **Smart Filtering:** Filter by trimester, category, and intensity level +Today, Move, Learn, Progress and You provide session previews, original Higgsfield artwork and optional decorative motion, trusted-resource links, personal records and data controls. Media is bundled locally; motion has pause and reduced-motion handling. There are 12 draft exercise records and 8 draft workout plans; a professionally reviewed production library remains outstanding. -### Workout Programs -- **Guided Workout Plans:** 8 pre-built programs (5-15 minutes each) -- **Trimester-Tailored:** Programs specifically designed for each pregnancy stage -- **Custom Workouts:** Build your own workout from the exercise library +Native records use iOS keychain/Android encrypted storage. Web records use versioned browser storage with a local recovery copy. Exported JSON backups contain private information. Local mirrors cannot protect against clearing all browser data or losing a device. Full local deletion includes profile, history, active timer and recovery copies; store/provider purchase records are separate. -### Active Workout -- **Real-time Timer:** Countdown timers with audio cues -- **Exercise Queue:** Intuitive queue system for workout flow -- **Rest Intervals:** Configurable rest periods between exercises +## Development -### Progress Tracking -- **Activity History:** Weekly activity charts and statistics -- **Streak Tracking:** Maintain workout consistency -- **Achievement Badges:** Unlock achievements for milestones +Node 22+: -### Mobile-First -- **Offline Capable:** State persisted locally for on-the-go workouts -- **Native Apps:** iOS and Android via Capacitor -- **PWA Support:** Install on any device - -## Tech Stack - -| Category | Technology | -|----------|------------| -| Frontend | React 19 + TypeScript | -| Styling | Tailwind CSS v4 + Lucide Icons | -| State | Zustand (persisted) | -| Routing | React Router v7 | -| Mobile | Capacitor 8 (iOS + Android) | -| Build | Vite | -| Charts | Recharts | -| Audio | Web Audio API | - -## Prerequisites - -- Node.js 18+ -- npm 9+ -- For native builds: Android Studio and/or Xcode - -## Installation - -```bash -# Clone the repository -git clone https://github.com/camster91/prego.git -cd prego - -# Install dependencies -npm install - -# Start development server +```sh +npm ci npm run dev -``` - -## Usage - -### Development - -```bash -# Start Vite dev server -npm run dev - -# Preview production build -npm run preview -``` - -### Native Mobile - -```bash -# Build web assets +npm run lint +npm test npm run build - -# Sync to native projects -npx cap sync - -# Open in Android Studio -npx cap open android - -# Open in Xcode -npx cap open ios +npm run test:e2e ``` -### Testing +For the browser suite, first run `npx playwright install chromium`. Production builds generate a versioned offline service worker for all bundled assets. Native apps use bundled web assets without a browser service worker. -```bash -# Run tests -npm run test +## Native builds -# Run tests in watch mode -npm run test:watch +```sh +npm run native:sync +npm run native:android +npm run native:ios ``` -## Project Structure +Android requires JDK 21 and SDK 36. Run `./gradlew assembleDebug bundleRelease lintDebug` from android/. iOS requires macOS/current Xcode. Minimum app OS: iOS 16.4; Android API 24 with current WebView. Unsigned bundles and simulator builds are validation artifacts, not store submissions. -``` -src/ -├── components/ # React components -│ ├── exercises/ # Exercise card and detail components -│ ├── workout/ # Timer, queue, and workout player -│ ├── progress/ # Charts and achievement components -│ └── ui/ # Base UI components -├── pages/ # Route pages -├── hooks/ # Custom React hooks -├── store/ # Zustand state management -├── data/ # Exercise and workout data -├── utils/ # Utility functions -└── types/ # TypeScript definitions - -public/ -└── sounds/ # Audio files for timer cues - -server/ -└── index.js # Express server (optional backend) -``` +## Billing -## Configuration +The native RevenueCat adapter uses the current offering's lifetime package and named `pregnancy_pass` entitlement. Configure store products and public native SDK keys, then complete real sandbox purchase/restore tests. Leave VITE_PURCHASES_ENABLED=false until release gates pass. Never place secret provider keys in VITE_* variables. The Express server serves the app and a health check; legacy Stripe routes return HTTP 410 and cannot initiate payments or customer portal sessions. -### Capacitor +## Internal testing -Edit `capacitor.config.ts` for native app configuration: - -```typescript -import type { CapacitorConfig } from '@capacitor/cli'; - -const config: CapacitorConfig = { - appId: 'com.yourcompany.prego', - appName: 'Prego', - webDir: 'dist', - server: { - androidScheme: 'https' - } -}; - -export default config; -``` - -### Environment Variables - -Create a `.env` file if needed for backend features: - -```env -VITE_API_URL=https://api.example.com -``` - -## Store Submission Checklist - -Before submitting to app stores: - -- [ ] Generate release keystore for Android signing -- [ ] Generate production App Icons and Splash Screens -- [ ] Create Apple Developer provisioning profiles -- [ ] Finalize store listing metadata - - [ ] App screenshots (phone, tablet) - - [ ] Privacy Policy URL - - [ ] App description and keywords -- [ ] Configure app.json / app.config.js - -## Available Scripts - -| Script | Description | -|--------|-------------| -| `npm run dev` | Start development server | -| `npm run build` | Build for production | -| `npm run start` | Start production server | -| `npm run lint` | Run ESLint | -| `npm run test` | Run Vitest | -| `npm run preview` | Preview production build | - -## Data Structure - -### Exercise -```typescript -interface Exercise { - id: string; - name: string; - description: string; - trimester: 1 | 2 | 3 | 'all'; - category: 'strength' | 'stretch' | 'cardio' | 'breathing'; - intensity: 'low' | 'medium' | 'high'; - duration: number; // seconds - instructions: string[]; -} -``` - -### Workout -```typescript -interface Workout { - id: string; - name: string; - description: string; - trimester: 1 | 2 | 3 | 'all'; - exercises: Exercise[]; - totalDuration: number; -} -``` +The requested beta targets are TestFlight internal testing and Google Play's internal track. The manual Native validation workflow can sign/upload to both after configuration and passing checks; PRs never distribute. See [secure setup and tester instructions](docs/INTERNAL-TESTING.md). Guided workouts and purchases remain disabled for this usability beta. No upload is claimed until signed artifacts, store processing and tester installation are verified. -## Contributing +Restore/deletion now use a verified transaction journal before updating in-memory records. Failed replacements roll back; interrupted operations recover before app hydration. Native journals use the same encrypted storage as records. Keep an exported backup for device loss or full browser-storage deletion. -1. Fork the repository -2. Create a feature branch -3. Commit your changes -4. Push and open a Pull Request +## Project map -## License +src/ contains UI, routes, stores, data and hooks; src/test/ contains Vitest regression tests; e2e/ contains Playwright/axe checks. ios/ and android/ are native projects. assets/ preserves original generated artwork; public/media/ contains optimised runtime media. docs/ holds requirements, evidence and remaining release work. -Proprietary - All rights reserved. +- [Product requirements](docs/PRODUCT-REQUIREMENTS.md) +- [Capability map](CAPABILITY-MAP.md) +- [Release evidence and blockers](docs/RELEASE-READINESS.md) +- [Native release runbook](docs/NATIVE-RELEASE-RUNBOOK.md) +- [Clinical/content review brief](docs/CONTENT-REVIEW-BRIEF.md) +- [Remaining implementation backlog](docs/IMPLEMENTATION-BACKLOG.md) +- [Custom media provenance](docs/ASSET-PROVENANCE.md) ---- -Developed by Cameron Ashley / Nexus AI. +GitHub shipping epic #41 remains the parent tracker. Preserve existing data issue #18 and CI issue #42. Licensed under MIT; see [LICENSE](LICENSE). The README update and licensing PRs are integrated with the mobile-readiness work. diff --git a/android/app/build.gradle b/android/app/build.gradle index 4dd885f..f35bd1b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.prego.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + versionCode = (System.getenv("PREGO_BUILD_NUMBER") ?: "1").toInteger() + versionName = System.getenv("PREGO_VERSION_NAME") ?: "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -16,8 +16,19 @@ android { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + signingConfigs { + if (System.getenv("PREGO_KEYSTORE_PATH")) { + internalTesting { + storeFile file(System.getenv("PREGO_KEYSTORE_PATH")) + storePassword System.getenv("PREGO_KEYSTORE_PASSWORD") + keyAlias System.getenv("PREGO_KEY_ALIAS") + keyPassword System.getenv("PREGO_KEY_PASSWORD") + } + } + } buildTypes { release { + if (System.getenv("PREGO_KEYSTORE_PATH")) signingConfig signingConfigs.internalTesting minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index bbfb44f..4aa6f09 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,7 +9,12 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { - + implementation project(':aparajita-capacitor-secure-storage') + implementation project(':capacitor-app') + implementation project(':capacitor-filesystem') + implementation project(':capacitor-preferences') + implementation project(':capacitor-share') + implementation project(':revenuecat-purchases-capacitor') } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b06ddbf..c873384 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/"); + await expect(page.getByRole("heading", { level: 1 })).toContainText( + "A little movement.", + ); + await page.screenshot({ + path: `test-results/today-${info.project.name}.png`, + fullPage: true, + }); + for (const [name, heading] of [ + ["Today", "A little movement."], + ["Move", "Find your kind"], + ["Learn", "A little knowledge"], + ["Progress", "Progress"], + ["You", ""], + ] as const) { + await page + .getByRole("navigation", { name: "Main navigation" }) + .getByRole("link", { name, exact: true }) + .click(); + if (heading) + await expect(page.getByRole("heading", { level: 1 })).toContainText( + heading, + ); + await expect + .poll(() => + page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + ) + .toBeTruthy(); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .analyze(); + expect( + results.violations.map((value) => ({ + id: value.id, + nodes: value.nodes.map((node) => node.target), + })), + ).toEqual([]); + } + expect(errors).toEqual([]); +}); +test("profile persists and reset removes both profile and history", async ({ + page, +}) => { + await onboard(page); + await page.reload(); + await expect(page.getByText("Hello, Alex.")).toBeVisible(); + await page.getByRole("link", { name: "You", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Alex", exact: true }), + ).toBeVisible(); + const accessibility = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .analyze(); + expect( + accessibility.violations.map((value) => ({ + id: value.id, + nodes: value.nodes.map((node) => node.target), + })), + ).toEqual([]); + await page.getByRole("button", { name: "Delete data" }).click(); + await page.getByRole("button", { name: "Reset Everything" }).click(); + await expect(page).toHaveURL(/onboarding/); + const saved = await page.evaluate(() => + Object.keys(localStorage).filter((key) => key.startsWith("prego-")), + ); + expect(saved).toEqual([]); +}); +test("unreviewed sessions and forged purchase URLs cannot grant access", async ({ + page, +}) => { + await page.goto("/workout/quick-stretch/active"); + await expect( + page.getByRole("heading", { name: "This session is not available yet" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Start workout", exact: true }), + ).toHaveCount(0); + await page.goto("/success?session_id=forged"); + await expect( + page.getByText("A checkout link does not confirm access.", { + exact: false, + }), + ).toBeVisible(); + await expect( + page.getByText("Your subscription is active.", { exact: false }), + ).toHaveCount(0); +}); +test("offline launch opens every bundled destination", async ({ + page, + context, +}) => { + await page.goto("/"); + await page.evaluate(async () => { + const registration = await navigator.serviceWorker.ready; + if (!registration.active) throw new Error("No active worker"); + }); + await page.reload(); + await expect + .poll(() => page.evaluate(() => !!navigator.serviceWorker.controller)) + .toBeTruthy(); + await context.setOffline(true); + await page.goto("/learn"); + await expect( + page.getByRole("heading", { name: "A little knowledge. More confidence." }), + ).toBeVisible(); + await page.goto("/workouts"); + await expect( + page.getByRole("heading", { name: "Find your kind of movement." }), + ).toBeVisible(); +}); +test("dark mode remains readable without horizontal overflow", async ({ + page, +}) => { + await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" }); + await page.goto("/"); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .analyze(); + expect( + results.violations.map((value) => ({ + id: value.id, + nodes: value.nodes.map((node) => ({ + target: node.target, + summary: node.failureSummary, + })), + })), + ).toEqual([]); + await expect + .poll(() => + page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + ) + .toBeTruthy(); +}); + +test('backup restore replaces records and a failed replacement preserves the previous profile', async ({ page }) => { + await onboard(page); + await page.getByRole('link', { name: 'You', exact: true }).click(); + const backup = await page.evaluate(() => ({ + format: 'prego-backup', version: 1, exportedAt: new Date().toISOString(), isOnboarded: true, + profile: { ...JSON.parse(localStorage.getItem('prego-user-storage')!).state.profile, displayName: 'Sam' }, + completedSessions: [], + })); + await page.locator('input[type=file]').setInputFiles({ name: 'backup.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(backup)) }); + await page.getByRole('button', { name: 'Restore this backup' }).click(); + await expect(page.getByRole('heading', { name: 'Sam', exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole('heading', { name: 'Sam', exact: true })).toBeVisible(); + await page.evaluate(() => { + const original = Storage.prototype.setItem; + let failed = false; + Storage.prototype.setItem = function (key, value) { + if (!failed && key === 'prego-user-storage') { failed = true; throw new Error('Simulated disk failure'); } + return original.call(this, key, value); + }; + }); + backup.profile.displayName = 'Taylor'; + await page.locator('input[type=file]').setInputFiles({ name: 'backup.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(backup)) }); + await page.getByRole('button', { name: 'Restore this backup' }).click(); + await expect(page.getByRole('status')).toContainText('Restoration could not finish'); + await page.getByRole('button', { name: 'Close', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Sam', exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole('heading', { name: 'Sam', exact: true })).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem('prego-data-transaction'))).toBeNull(); +}); diff --git a/eslint.config.js b/eslint.config.js index ed280db..1d88aab 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'android/**/build/**', 'ios/build/**']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/index.html b/index.html index 10544c9..4aca708 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - - - + + + @@ -12,16 +12,16 @@ - + - + Prego - Pregnancy Exercise - - + + diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..f470299 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 0000000..93f537d --- /dev/null +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,384 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + AA0000000000000000000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000002 /* PrivacyInfo.xcprivacy */; }; + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AA0000000000000000000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */, + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + AA0000000000000000000002 /* PrivacyInfo.xcprivacy */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + AA0000000000000000000001 /* PrivacyInfo.xcprivacy in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.prego.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.prego.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift new file mode 100644 index 0000000..fec95ac --- /dev/null +++ b/ios/App/App/AppDelegate.swift @@ -0,0 +1,44 @@ +import UIKit +import Capacitor + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let config = UISceneConfiguration(name: "Default Configuration", + sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config + } +} diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000..2cf9ed3 Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9b7d382 --- /dev/null +++ b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/App/Assets.xcassets/Contents.json b/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 0000000..d7d96a6 --- /dev/null +++ b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 0000000..0f985db Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 0000000..0f985db Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 0000000..0f985db Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png differ diff --git a/ios/App/App/Base.lproj/LaunchScreen.storyboard b/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..e7ae5d7 --- /dev/null +++ b/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Base.lproj/Main.storyboard b/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 0000000..b44df7b --- /dev/null +++ b/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist new file mode 100644 index 0000000..557d0da --- /dev/null +++ b/ios/App/App/Info.plist @@ -0,0 +1,70 @@ + + + + + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Prego + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/App/App/PrivacyInfo.xcprivacy b/ios/App/App/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..2f8d6c3 --- /dev/null +++ b/ios/App/App/PrivacyInfo.xcprivacy @@ -0,0 +1,31 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/ios/App/App/SceneDelegate.swift b/ios/App/App/SceneDelegate.swift new file mode 100644 index 0000000..f352e1e --- /dev/null +++ b/ios/App/App/SceneDelegate.swift @@ -0,0 +1,24 @@ +import UIKit +import Capacitor + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + + window = UIWindow(windowScene: windowScene) + window?.rootViewController = CAPBridgeViewController() + window?.makeKeyAndVisible() + + SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + SceneDelegateProxy.shared.scene(scene, continue: userActivity) + } +} diff --git a/ios/App/CapApp-SPM/.gitignore b/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 0000000..b8d7a37 --- /dev/null +++ b/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v16)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.3"), + .package(name: "AparajitaCapacitorSecureStorage", path: "../../../node_modules/@aparajita/capacitor-secure-storage"), + .package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"), + .package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"), + .package(name: "CapacitorPreferences", path: "../../../node_modules/@capacitor/preferences"), + .package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"), + .package(name: "RevenuecatPurchasesCapacitor", path: "../../../node_modules/@revenuecat/purchases-capacitor") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "AparajitaCapacitorSecureStorage", package: "AparajitaCapacitorSecureStorage"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"), + .product(name: "CapacitorPreferences", package: "CapacitorPreferences"), + .product(name: "CapacitorShare", package: "CapacitorShare"), + .product(name: "RevenuecatPurchasesCapacitor", package: "RevenuecatPurchasesCapacitor") + ] + ) + ] +) diff --git a/ios/App/CapApp-SPM/README.md b/ios/App/CapApp-SPM/README.md new file mode 100644 index 0000000..03964db --- /dev/null +++ b/ios/App/CapApp-SPM/README.md @@ -0,0 +1,5 @@ +# CapApp-SPM + +This package is used to host SPM dependencies for your Capacitor project + +Do not modify the contents of it or there may be unintended consequences. diff --git a/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 0000000..945afec --- /dev/null +++ b/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/ios/debug.xcconfig b/ios/debug.xcconfig new file mode 100644 index 0000000..53ce18d --- /dev/null +++ b/ios/debug.xcconfig @@ -0,0 +1 @@ +CAPACITOR_DEBUG = true diff --git a/package-lock.json b/package-lock.json index 26a87d8..9931475 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,42 +8,50 @@ "name": "prego", "version": "1.0.0", "dependencies": { - "@capacitor/android": "^8.5.0", - "@capacitor/cli": "^8.5.0", - "@capacitor/core": "^8.4.2", - "@tanstack/react-query": "^5.102.1", + "@aparajita/capacitor-secure-storage": "^8.0.1", + "@capacitor/android": "^8.5.3", + "@capacitor/app": "^8.1.2", + "@capacitor/core": "^8.5.3", + "@capacitor/filesystem": "^8.1.4", + "@capacitor/ios": "^8.5.3", + "@capacitor/preferences": "^8.0.1", + "@capacitor/share": "^8.0.3", + "@revenuecat/purchases-capacitor": "^13.7.3", + "@tanstack/react-query": "^5.103.1", "date-fns": "^4.1.0", - "express": "^4.18.2", + "express": "^4.22.3", "lucide-react": "^0.577.0", - "react": "^19.2.8", - "react-dom": "^19.2.8", - "react-router-dom": "^7.18.2", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router-dom": "^7.18.4", "recharts": "^3.10.1", - "stripe": "^14.0.0", "zustand": "^5.0.15" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", + "@capacitor/cli": "^8.5.3", "@eslint/js": "^9.39.5", + "@playwright/test": "^1.64.0", "@tailwindcss/postcss": "^4.3.3", "@testing-library/jest-dom": "^6.10.0", - "@testing-library/react": "^16.3.2", - "@types/node": "^24.13.3", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", + "@testing-library/react": "^16.3.3", + "@types/node": "^24.13.5", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^5.1.1", - "autoprefixer": "^10.5.4", + "autoprefixer": "^10.6.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "eslint": "^9.39.5", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.4", + "eslint-plugin-react-refresh": "^0.5.7", "globals": "^16.5.0", "jsdom": "^28.0.0", - "postcss": "^8.5.26", - "tailwind-merge": "^3.4.0", + "postcss": "^8.5.28", + "tailwind-merge": "^3.7.0", "tailwindcss": "^4.1.18", "typescript": "~5.9.3", - "typescript-eslint": "^8.67.0", + "typescript-eslint": "^8.70.0", "vite": "^7.3.6", "vitest": "^4.1.11" } @@ -75,6 +83,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@aparajita/capacitor-secure-storage": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@aparajita/capacitor-secure-storage/-/capacitor-secure-storage-8.0.1.tgz", + "integrity": "sha512-NSYIQtH6TyBgL3myumQGcCE3iecq2PtGNFlGxl1fJKEU37Dv5DudzDlwg42cgbVxCm1ay72g4X7rTSAvKULsVg==", + "license": "MIT", + "dependencies": { + "@capacitor/android": "^8.0.2", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.0.2", + "@capacitor/ios": "^8.0.2", + "@capacitor/keyboard": "^8.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -133,6 +157,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -459,18 +496,28 @@ } }, "node_modules/@capacitor/android": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.0.tgz", - "integrity": "sha512-Rb3prJeQiTp0pQhSSOReJuG9VgibOGMG4ECs+UhMwr6TCCHZ3NCZO811bDA0HxD0xrT/gCrJAsY5yfEATu84JQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.3.tgz", + "integrity": "sha512-ilH4gSSz3DrketMkzDVAvY9JsWJ//rCQnECLTdcFRO7xXDogwAbUvQAnIAbiqefwV/D1xfjEL4Gw1MJXEcaA9g==", "license": "MIT", "peerDependencies": { "@capacitor/core": "^8.5.0" } }, + "node_modules/@capacitor/app": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.2.tgz", + "integrity": "sha512-uKBlsrXH0IdvUn69e/KKYbZ32A06pcrUdG8LWuZ/6y33YMXZXLEEdNSwl+jKS/SrsTWqnePs8EqSWaiQM3mUGA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/cli": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.0.tgz", - "integrity": "sha512-rLdzMUM5QV4WITcqoWv04p32i14BXgUH2diqEH6MWQlWaJfiyNrvOyt/+d5vHAfOxOm1klBu637VDEobctlwBA==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.3.tgz", + "integrity": "sha512-/G6xwna+p4GS3lthSdWN3OThCH2F85O0Ukdy8rv3oAXvgEMeyFgPEIA3PMAS++IkHwUuBYBAKOQJnZfcrh1n0w==", + "dev": true, "license": "MIT", "dependencies": { "@ionic/cli-framework-output": "^2.2.8", @@ -501,14 +548,68 @@ } }, "node_modules/@capacitor/core": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.0.tgz", - "integrity": "sha512-Ca4krtqH1hothjtBIwf2J2TW7IhYq1ujp8QeItTiJohNsqij8ja2DYYH3DU0l8RmxCWaBAFTGA2TgOgOMCSNsQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.3.tgz", + "integrity": "sha512-oBE1oGuYE4ZzYYrulC75OuHdSsD6zLUy7d5o1tFwDLG4govGoubY15cxpiI5l0+q87Lm5p0bhbehMdzJg4aFag==", "license": "MIT", "dependencies": { "tslib": "^2.1.0" } }, + "node_modules/@capacitor/filesystem": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.4.tgz", + "integrity": "sha512-8MXmt10cb+dGWoh8jUeO8on0U6iETQCX5k+fmdB278PZ3MYDEVGQo08+hroaOtUbK3+5stiRk4/K6QBHKepv0A==", + "license": "MIT", + "dependencies": { + "@capacitor/synapse": "^1.0.4" + }, + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/ios": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.3.tgz", + "integrity": "sha512-HnnV92Jl8+3x0JUloXUMjFcUsQI8wHFVsKwkLn72DfRZXYGZW11tQbmNOWOnRTljaU18FdI1b9ipT09SzzPDNw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.5.0" + } + }, + "node_modules/@capacitor/keyboard": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.6.tgz", + "integrity": "sha512-Bbe+9V0qaT6dzEFJEK7BUWR01l2+vZE8FM5ITF/WgKtx+G6eGZ6d5+YiSMRJBAK0VGjdO59v8yiQ10Doa5KyvQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/preferences": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz", + "integrity": "sha512-T6no3ebi79XJCk91U3Jp/liJUwgBdvHR+s6vhvPkPxSuch7z3zx5Rv1bdWM6sWruNx+pViuEGqZvbfCdyBvcHQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/share": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.3.tgz", + "integrity": "sha512-gnvZBV1NPu2B8vd1UKncR56G15ueHd0aZLqgDov++t3SmTBG9Q0VrAZ1VnHn5stxlmglVaTb4RwopMgbpLzroA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/synapse": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", + "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", + "license": "ISC" + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -650,9 +751,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -667,9 +768,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -684,9 +785,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -701,9 +802,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -718,9 +819,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -735,9 +836,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -752,9 +853,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -769,9 +870,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -786,9 +887,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -803,9 +904,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -820,9 +921,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -837,9 +938,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -854,9 +955,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -871,9 +972,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -888,9 +989,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -905,9 +1006,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -922,9 +1023,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -939,9 +1040,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -956,9 +1057,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -973,9 +1074,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -990,9 +1091,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -1007,9 +1108,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -1024,9 +1125,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -1041,9 +1142,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -1058,9 +1159,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -1075,9 +1176,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1336,6 +1437,7 @@ "version": "2.2.8", "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, "license": "MIT", "dependencies": { "@ionic/utils-terminal": "2.3.5", @@ -1350,6 +1452,7 @@ "version": "2.1.6", "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.0.0", @@ -1363,6 +1466,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^8.0.0", @@ -1378,6 +1482,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", @@ -1393,6 +1498,7 @@ "version": "2.1.6", "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.0.0", @@ -1406,6 +1512,7 @@ "version": "2.1.12", "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, "license": "MIT", "dependencies": { "@ionic/utils-object": "2.1.6", @@ -1423,6 +1530,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.0.0", @@ -1436,6 +1544,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, "license": "MIT", "dependencies": { "@ionic/utils-array": "2.1.6", @@ -1455,6 +1564,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, "license": "MIT", "dependencies": { "@types/slice-ansi": "^4.0.0", @@ -1475,6 +1585,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -1533,6 +1644,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -1559,6 +1686,24 @@ } } }, + "node_modules/@revenuecat/purchases-capacitor": { + "version": "13.7.3", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-capacitor/-/purchases-capacitor-13.7.3.tgz", + "integrity": "sha512-HqA2uxjUoaCR+a8MYfts/r+4h+WpCIaRMCknBAs1cd9+XOk6Ft/5qKe/D0SmDMGTpoSN1w0qAKvJu8FfokwLhQ==", + "license": "MIT", + "dependencies": { + "@revenuecat/purchases-typescript-internal-esm": "19.10.0" + }, + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@revenuecat/purchases-typescript-internal-esm": { + "version": "19.10.0", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal-esm/-/purchases-typescript-internal-esm-19.10.0.tgz", + "integrity": "sha512-pIk9P6zztSJYZ38AUxRNmuvKGhwo6mJEfzxw2poFQR8Np5knUbWPwAHBLNg0nWDl++RBTcHxvaibnUhTvOkRbg==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1658,9 +1803,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1675,9 +1817,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1692,9 +1831,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1709,9 +1845,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1726,9 +1859,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1743,9 +1873,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1760,9 +1887,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1777,9 +1901,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1794,9 +1915,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1811,9 +1929,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1828,9 +1943,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1845,9 +1957,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1862,9 +1971,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2100,9 +2206,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2120,9 +2223,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2140,9 +2240,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2160,9 +2257,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2317,9 +2411,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.1.tgz", - "integrity": "sha512-bJTsrO2ODWSgg+x1leTsK036GBtmNNjnrF1Vk06J5fg/phYZLQIdXrBe0uf5xTItXYvIW1MtbLhyPw7cPMqUUQ==", + "version": "5.104.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.2.tgz", + "integrity": "sha512-/qbsxJK5c3Ve1aFIULWYNcT2jeRUAiD4HME6Eu2ZdpofHoVgAjpkQhLg5xHuOoBkTPQUJLfP7zOxy0qsBcqROA==", "license": "MIT", "funding": { "type": "github", @@ -2327,12 +2421,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.1.tgz", - "integrity": "sha512-DKJeRvxjsGUWhUVnVVXSseTS0SKBMLSkrz8dm7i08r93IQQ2gAfUkX1e801gVZ18KSwHiG5yng4tQm5+qSGS0w==", + "version": "5.104.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.2.tgz", + "integrity": "sha512-xKmzWGhadbE5wGS/ZmXShRITnI9oItox5GO14KvFZco3+vhTAyXS1erYzJWdiY280zapAGJXFUL9nHgwP6SV/g==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.102.1" + "@tanstack/query-core": "5.104.2" }, "funding": { "type": "github", @@ -2394,9 +2488,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "dev": true, "license": "MIT", "dependencies": { @@ -2566,6 +2660,7 @@ "version": "8.1.5", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2579,18 +2674,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2598,19 +2694,20 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/slice-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/use-sync-external-store": { @@ -2620,17 +2717,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", - "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/type-utils": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2643,15 +2740,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/parser": "^8.71.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -2659,16 +2756,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3" }, "engines": { @@ -2684,14 +2781,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", - "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.67.0", - "@typescript-eslint/types": "^8.67.0", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -2706,14 +2803,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", - "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2724,9 +2821,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", - "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { @@ -2741,15 +2838,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", - "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2766,9 +2863,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", - "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "dev": true, "license": "MIT", "engines": { @@ -2780,16 +2877,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", - "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.67.0", - "@typescript-eslint/tsconfig-utils": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2818,9 +2915,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2847,16 +2944,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", - "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0" + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2871,13 +2968,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", - "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/types": "8.71.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3036,9 +3133,10 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", - "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=14.6" @@ -3111,6 +3209,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3120,6 +3219,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3168,6 +3268,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3177,15 +3278,16 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, "license": "ISC", "engines": { "node": ">= 4.0.0" } }, "node_modules/autoprefixer": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", - "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", "dev": true, "funding": [ { @@ -3203,8 +3305,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.6", - "caniuse-lite": "^1.0.30001806", + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -3219,6 +3321,16 @@ "postcss": "^8.1.0" } }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3230,6 +3342,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -3247,9 +3360,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.44", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz", - "integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3273,15 +3386,16 @@ "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, "license": "Unlicense", "engines": { "node": ">=0.6" } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -3292,7 +3406,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -3321,6 +3435,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "dev": true, "license": "MIT", "dependencies": { "stream-buffers": "2.2.x" @@ -3330,6 +3445,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, "license": "MIT", "dependencies": { "big-integer": "1.6.x" @@ -3339,9 +3455,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3350,9 +3466,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -3370,11 +3486,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -3387,6 +3503,7 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -3441,9 +3558,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "dev": true, "funding": [ { @@ -3492,6 +3609,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -3523,6 +3641,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3535,12 +3654,14 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -3600,6 +3721,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3813,6 +3935,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3850,6 +3973,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3923,9 +4047,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.393", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", - "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", "dev": true, "license": "ISC" }, @@ -3933,6 +4057,7 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "sax": "1.1.4" @@ -3945,6 +4070,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -3987,6 +4113,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4040,9 +4167,9 @@ ] }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4053,32 +4180,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -4191,9 +4318,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.4.tgz", - "integrity": "sha512-7bqTKz7T0r+HKWFarNXByDE9/5+73wI2ru+M3zuqGbR7s/b/5/pQJXZoufWlrngqGqoZto73ZkGumCdLxk+4rw==", + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4330,9 +4457,9 @@ } }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -4354,9 +4481,9 @@ "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", + "path-to-regexp": "~0.1.13", "proxy-addr": "~2.0.7", - "qs": "~6.15.1", + "qs": "~6.16.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -4415,6 +4542,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -4558,6 +4686,7 @@ "version": "11.3.5", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -4643,6 +4772,7 @@ "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -4673,27 +4803,30 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -4734,6 +4867,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -4927,6 +5061,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -4954,6 +5089,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -4979,6 +5115,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5008,6 +5145,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, "license": "MIT", "dependencies": { "is-docker": "^2.0.0" @@ -5020,6 +5158,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/jiti": { @@ -5040,9 +5179,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -5154,6 +5293,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -5176,6 +5316,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5338,9 +5479,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5362,9 +5500,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5386,9 +5521,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5410,9 +5542,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5634,6 +5763,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -5643,6 +5773,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -5658,9 +5789,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -5680,6 +5811,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, "license": "MIT", "dependencies": { "@ionic/utils-fs": "^3.1.7", @@ -5718,9 +5850,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "dev": true, "license": "MIT", "engines": { @@ -5766,6 +5898,7 @@ "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, "license": "MIT", "dependencies": { "define-lazy-prop": "^2.0.0", @@ -5833,6 +5966,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -5884,6 +6018,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5893,6 +6028,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -5909,6 +6045,7 @@ "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -5931,6 +6068,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -5953,10 +6091,40 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.9.10", @@ -5968,9 +6136,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "dev": true, "funding": [ { @@ -5988,9 +6156,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -6055,6 +6223,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, "license": "MIT", "dependencies": { "kleur": "^3.0.3", @@ -6068,15 +6237,16 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -6084,6 +6254,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/punycode": { @@ -6097,12 +6271,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -6136,24 +6311,24 @@ } }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.0" } }, "node_modules/react-is": { @@ -6197,9 +6372,9 @@ } }, "node_modules/react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6219,12 +6394,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", - "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", "license": "MIT", "dependencies": { - "react-router": "7.18.2" + "react-router": "7.18.4" }, "engines": { "node": ">=20.0.0" @@ -6251,6 +6426,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -6350,6 +6526,7 @@ "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "glob": "^13.0.3", @@ -6440,6 +6617,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, "license": "ISC" }, "node_modules/saxes": { @@ -6456,15 +6634,16 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6543,6 +6722,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6555,20 +6735,21 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -6643,12 +6824,14 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/simple-plist": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "dev": true, "license": "MIT", "dependencies": { "bplist-creator": "0.1.0", @@ -6660,6 +6843,7 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "dev": true, "license": "MIT", "dependencies": { "big-integer": "1.6.x" @@ -6672,12 +6856,14 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, "license": "MIT" }, "node_modules/slice-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -6692,9 +6878,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6705,6 +6891,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, "license": "ISC", "engines": { "node": ">= 10.x" @@ -6737,6 +6924,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "dev": true, "license": "Unlicense", "engines": { "node": ">= 0.10.0" @@ -6746,6 +6934,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -6755,6 +6944,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6769,6 +6959,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6803,19 +6994,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stripe": { - "version": "14.25.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz", - "integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==", - "license": "MIT", - "dependencies": { - "@types/node": ">=8.1.0", - "qs": "^6.11.0" - }, - "engines": { - "node": ">=12.*" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6837,9 +7015,9 @@ "license": "MIT" }, "node_modules/tailwind-merge": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", - "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", "dev": true, "license": "MIT", "funding": { @@ -6869,9 +7047,10 @@ } }, "node_modules/tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -6888,6 +7067,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -6897,6 +7077,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, "license": "MIT", "dependencies": { "readable-stream": "3" @@ -7011,6 +7192,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, "license": "MIT", "bin": { "tree-kill": "cli.js" @@ -7076,16 +7258,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", - "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.67.0", - "@typescript-eslint/parser": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0" + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7100,9 +7282,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "engines": { @@ -7110,15 +7292,17 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, "license": "MIT" }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -7137,15 +7321,16 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -7196,6 +7381,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/utils-merge": { @@ -7212,6 +7398,7 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -7465,6 +7652,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7507,6 +7695,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7524,6 +7713,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "simple-plist": "^1.1.0", @@ -7547,6 +7737,7 @@ "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, "license": "MIT", "dependencies": { "sax": ">=0.6.0", @@ -7560,6 +7751,7 @@ "version": "11.0.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, "license": "MIT", "engines": { "node": ">=4.0" @@ -7569,6 +7761,7 @@ "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.0" @@ -7592,6 +7785,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", diff --git a/package.json b/package.json index fb3a05b..fc1da6a 100644 --- a/package.json +++ b/package.json @@ -5,50 +5,63 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && node scripts/generate-service-worker.mjs", "start": "node server/index.js", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "typecheck": "tsc -b", + "native:sync": "npm run build && cap sync", + "native:ios": "cap open ios", + "native:android": "cap open android", + "test:e2e": "playwright test" }, "dependencies": { - "stripe": "^14.0.0", - "express": "^4.18.2", - "@capacitor/android": "^8.5.0", - "@capacitor/cli": "^8.5.0", - "@capacitor/core": "^8.4.2", - "@tanstack/react-query": "^5.102.1", + "@aparajita/capacitor-secure-storage": "^8.0.1", + "@capacitor/android": "^8.5.3", + "@capacitor/app": "^8.1.2", + "@capacitor/core": "^8.5.3", + "@capacitor/filesystem": "^8.1.4", + "@capacitor/ios": "^8.5.3", + "@capacitor/preferences": "^8.0.1", + "@capacitor/share": "^8.0.3", + "@revenuecat/purchases-capacitor": "^13.7.3", + "@tanstack/react-query": "^5.103.1", "date-fns": "^4.1.0", + "express": "^4.22.3", "lucide-react": "^0.577.0", - "react": "^19.2.8", - "react-dom": "^19.2.8", - "react-router-dom": "^7.18.2", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router-dom": "^7.18.4", "recharts": "^3.10.1", "zustand": "^5.0.15" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", + "@capacitor/cli": "^8.5.3", "@eslint/js": "^9.39.5", + "@playwright/test": "^1.64.0", "@tailwindcss/postcss": "^4.3.3", "@testing-library/jest-dom": "^6.10.0", - "@testing-library/react": "^16.3.2", - "@types/node": "^24.13.3", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", + "@testing-library/react": "^16.3.3", + "@types/node": "^24.13.5", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^5.1.1", - "autoprefixer": "^10.5.4", + "autoprefixer": "^10.6.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "eslint": "^9.39.5", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.4", + "eslint-plugin-react-refresh": "^0.5.7", "globals": "^16.5.0", "jsdom": "^28.0.0", - "postcss": "^8.5.26", - "tailwind-merge": "^3.4.0", + "postcss": "^8.5.28", + "tailwind-merge": "^3.7.0", "tailwindcss": "^4.1.18", "typescript": "~5.9.3", - "typescript-eslint": "^8.67.0", + "typescript-eslint": "^8.70.0", "vite": "^7.3.6", "vitest": "^4.1.11" } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..ae5ae2a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "@playwright/test"; +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + retries: process.env.CI ? 1 : 0, + use: { + baseURL: "http://127.0.0.1:4173", + trace: "retain-on-failure", + reducedMotion: "reduce", + }, + webServer: { + command: "npm run preview -- --host 127.0.0.1", + url: "http://127.0.0.1:4173", + reuseExistingServer: !process.env.CI, + }, + projects: [ + { name: "compact", use: { viewport: { width: 320, height: 720 } } }, + { name: "phone", use: { viewport: { width: 390, height: 844 } } }, + { name: "tablet", use: { viewport: { width: 768, height: 1024 } } }, + ], +}); diff --git a/public/manifest.json b/public/manifest.json index c9a53da..a3b8659 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,24 +1,27 @@ { - "name": "Prego - Pregnancy Exercise", + "name": "Prego \u2014 Movement for every stage", "short_name": "Prego", - "description": "Safe, guided pregnancy exercises for every trimester", + "description": "Pregnancy movement education and your personal progress.", "start_url": "/", "display": "standalone", - "background_color": "#f8faf8", - "theme_color": "#0ea5e9", + "background_color": "#FAF8F4", + "theme_color": "#22332C", "icons": [ { - "src": "/icon.svg", - "sizes": "any", - "type": "image/svg+xml", + "src": "/media/icon-192.png", + "sizes": "192x192", + "type": "image/png", "purpose": "any" }, { - "src": "/icon.svg", + "src": "/media/icon-512.png", "sizes": "512x512", - "type": "image/svg+xml", - "purpose": "maskable" + "type": "image/png", + "purpose": "any maskable" } ], - "categories": ["health", "fitness"] + "categories": [ + "health", + "fitness" + ] } diff --git a/public/media/icon-192.png b/public/media/icon-192.png new file mode 100644 index 0000000..c79634d Binary files /dev/null and b/public/media/icon-192.png differ diff --git a/public/media/icon-512.png b/public/media/icon-512.png new file mode 100644 index 0000000..2899893 Binary files /dev/null and b/public/media/icon-512.png differ diff --git a/public/media/prego-studio.webp b/public/media/prego-studio.webp new file mode 100644 index 0000000..93a44ea Binary files /dev/null and b/public/media/prego-studio.webp differ diff --git a/public/media/studio-loop.mp4 b/public/media/studio-loop.mp4 new file mode 100644 index 0000000..caaf188 Binary files /dev/null and b/public/media/studio-loop.mp4 differ diff --git a/public/media/studio-poster.webp b/public/media/studio-poster.webp new file mode 100644 index 0000000..bd73fab Binary files /dev/null and b/public/media/studio-poster.webp differ diff --git a/public/sw.js b/public/sw.js index 4661db0..3474d73 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,63 +1 @@ -const CACHE_NAME = 'prego-v1'; -const STATIC_ASSETS = [ - '/', - '/manifest.json', - '/icon.svg' -]; - -// Install: cache static assets -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) - ); - self.skipWaiting(); -}); - -// Activate: clean up old caches -self.addEventListener('activate', (event) => { - event.waitUntil( - caches.keys().then((keys) => - Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))) - ) - ); - self.clients.claim(); -}); - -// Fetch: network-first for navigation, cache-first for static assets -self.addEventListener('fetch', (event) => { - const { request } = event; - - // Skip non-GET requests - if (request.method !== 'GET') return; - - // Skip API requests - if (request.url.includes('/api/')) return; - - // Navigation requests: network-first with offline fallback - if (request.mode === 'navigate') { - event.respondWith( - fetch(request) - .then((response) => { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); - return response; - }) - .catch(() => caches.match('/') || caches.match(request)) - ); - return; - } - - // Static assets: cache-first - event.respondWith( - caches.match(request).then((cached) => { - if (cached) return cached; - return fetch(request).then((response) => { - if (response.ok) { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); - } - return response; - }); - }) - ); -}); +// Production builds replace this file with the versioned offline asset manifest. diff --git a/scripts/check-beta-config.mjs b/scripts/check-beta-config.mjs new file mode 100644 index 0000000..ef1d1f6 --- /dev/null +++ b/scripts/check-beta-config.mjs @@ -0,0 +1,26 @@ +const platform = process.argv[2]; +const common = ['PREGO_VERSION_NAME', 'PREGO_BUILD_NUMBER']; +const required = platform === 'ios' + ? [...common, 'APPLE_TEAM_ID', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_PRIVATE_KEY', 'IOS_CERTIFICATE_BASE64', 'IOS_CERTIFICATE_PASSWORD', 'IOS_PROFILE_BASE64'] + : platform === 'android' + ? [...common, 'ANDROID_KEYSTORE_BASE64', 'PREGO_KEYSTORE_PASSWORD', 'PREGO_KEY_ALIAS', 'PREGO_KEY_PASSWORD', 'PLAY_SERVICE_ACCOUNT_JSON'] + : []; +if (!required.length) throw new Error('Expected ios or android'); +const missing = required.filter(key => !process.env[key]?.trim()); +if (missing.length) { + console.error(`Configure these GitHub Actions settings before uploading: ${missing.join(', ')}`); + process.exit(1); +} +if (!/^\d+\.\d+\.\d+$/.test(process.env.PREGO_VERSION_NAME)) throw new Error('Version must be major.minor.patch'); +const build = Number(process.env.PREGO_BUILD_NUMBER); +if (!Number.isInteger(build) || build < 1 || build > 2100000000) throw new Error('Build number must be 1–2100000000 and greater than the previous store build'); +if (platform === 'ios') { + if (!/^[A-Z0-9]{10}$/.test(process.env.APPLE_TEAM_ID)) throw new Error('Apple Team ID must have 10 uppercase letters/digits'); + if (process.env.APPLE_APP_ID && !/^\d+$/.test(process.env.APPLE_APP_ID)) throw new Error('App Store Connect app ID must be numeric'); +} else { + let account; + try { account = JSON.parse(process.env.PLAY_SERVICE_ACCOUNT_JSON); } + catch { throw new Error('Invalid Play service account JSON'); } + if (account.type !== 'service_account' || !account.client_email || !account.private_key) throw new Error('Invalid Play service account configuration'); +} +console.log(`${platform} signing settings are present. No credential values were logged.`); diff --git a/scripts/configure-beta-secrets.sh b/scripts/configure-beta-secrets.sh new file mode 100755 index 0000000..982985e --- /dev/null +++ b/scripts/configure-beta-secrets.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Run locally by the repository owner. Never runs in CI or prints secret values. +set -euo pipefail +set +x +PREGO_REPO='camster91/prego' +PREGO_PLATFORM="${1:-both}" +case "$PREGO_PLATFORM" in both|ios|android) ;; *) echo 'Usage: scripts/configure-beta-secrets.sh [both|ios|android]'; exit 1 ;; esac +command -v gh >/dev/null || { echo 'Install GitHub CLI first: https://cli.github.com'; exit 1; } +gh auth status >/dev/null 2>&1 || { echo 'Sign in locally with gh auth login using repository access.'; exit 1; } +echo "Configuring internal-testing credentials for $PREGO_REPO. Nothing will be uploaded to Apple or Google." +read_value() { + local prompt="$1" + read -r -p "$prompt: " PREGO_VALUE + [ -n "$PREGO_VALUE" ] || { echo 'A value is required.'; exit 1; } +} +set_variable() { + read_value "$2" + gh variable set "$1" --repo "$PREGO_REPO" --body "$PREGO_VALUE" + unset PREGO_VALUE +} +set_text_secret() { + read_value "$2" + printf '%s' "$PREGO_VALUE" | gh secret set "$1" --repo "$PREGO_REPO" + unset PREGO_VALUE +} +set_password() { + local secret="$1" + read -r -s -p "$2: " PREGO_PASSWORD + printf '\n' + [ -n "$PREGO_PASSWORD" ] || { echo 'A nonempty password is required.'; exit 1; } + printf '%s' "$PREGO_PASSWORD" | gh secret set "$secret" --repo "$PREGO_REPO" + unset PREGO_PASSWORD +} +set_file_secret() { + local secret="$1" encoding="$3" + read_value "$2 (absolute file path)" + [ -f "$PREGO_VALUE" ] || { echo 'File not found.'; exit 1; } + if [ "$encoding" = 'base64' ]; then + base64 < "$PREGO_VALUE" | tr -d '\r\n' | gh secret set "$secret" --repo "$PREGO_REPO" + else + gh secret set "$secret" --repo "$PREGO_REPO" < "$PREGO_VALUE" + fi + unset PREGO_VALUE +} +if [ "$PREGO_PLATFORM" = 'both' ] || [ "$PREGO_PLATFORM" = 'ios' ]; then + echo 'Using the existing Apple team NVJ5UY3T2X. Override APPLE_TEAM_ID in repository variables only if Prego uses another team.' + set_text_secret ASC_KEY_ID 'App Store Connect API key ID' + set_text_secret ASC_ISSUER_ID 'App Store Connect API issuer ID' + set_file_secret ASC_API_PRIVATE_KEY 'App Store Connect .p8 API key' plain + set_file_secret IOS_DISTRIBUTION_P12_BASE64 'Apple Distribution .p12 certificate and private key' base64 + set_password IOS_DISTRIBUTION_P12_PASSWORD 'Distribution .p12 password' + set_file_secret IOS_APP_STORE_PROFILE_BASE64 'App Store .mobileprovision profile for com.prego.app' base64 +fi +if [ "$PREGO_PLATFORM" = 'both' ] || [ "$PREGO_PLATFORM" = 'android' ]; then + set_file_secret ANDROID_UPLOAD_KEYSTORE_BASE64 'Existing Google Play upload keystore' base64 + set_password ANDROID_KEYSTORE_PASSWORD 'Upload keystore password' + set_text_secret ANDROID_KEY_ALIAS 'Upload key alias' + set_password ANDROID_KEY_PASSWORD 'Upload key password' + set_file_secret PLAY_SERVICE_ACCOUNT_JSON 'Authorized Google Play service-account JSON' plain +fi +echo 'Secure settings saved. Confirm the registered app IDs and next build number, then run the internal-testing workflow.' diff --git a/scripts/generate-service-worker.mjs b/scripts/generate-service-worker.mjs new file mode 100644 index 0000000..18e0a47 --- /dev/null +++ b/scripts/generate-service-worker.mjs @@ -0,0 +1,44 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +const walk = async (path) => + ( + await Promise.all( + (await readdir(path, { withFileTypes: true })).map(async (entry) => + entry.isDirectory() + ? walk(`${path}/${entry.name}`) + : `${path}/${entry.name}`, + ), + ) + ).flat(); +const files = (await walk("dist")).filter( + (file) => !file.endsWith("/sw.js") && !file.endsWith(".map"), +); +const digest = createHash("sha256"); +for (const file of files.sort()) + digest.update(file).update(await readFile(file)); +const assets = files.map((file) => "/" + file.replace(/^dist\//, "")); +await writeFile( + "dist/sw.js", + `const CACHE = 'prego-${digest.digest("hex").slice(0, 16)}'; +const ASSETS = ${JSON.stringify(assets)}; +self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)))); +// Keep updates waiting until all old app windows close. Never swap a workout's +// content/assets underneath a running session. +self.addEventListener('activate', event => event.waitUntil((async () => { const keys = await caches.keys(); await Promise.all(keys.filter(key => key.startsWith('prego-') && key !== CACHE).map(key => caches.delete(key))); await self.clients.claim(); })())); +self.addEventListener('fetch', event => { + const request = event.request; + const url = new URL(request.url); + if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return; + event.respondWith((async () => { + const cache = await caches.open(CACHE); + if (request.mode === 'navigate') { + try { const response = await fetch(request); if (response.ok) return response; } catch {} + return (await cache.match('/index.html')) ?? new Response('Prego is unavailable offline. Connect once to download the app.', { status: 503 }); + } + // Bundled assets are public, immutable and independent of request Origin. + const cached = await cache.match(request, { ignoreVary: true }); + return cached ?? fetch(request); + })()); +}); +`, +); diff --git a/scripts/prepare-ios-signing.sh b/scripts/prepare-ios-signing.sh new file mode 100755 index 0000000..9f17e6c --- /dev/null +++ b/scripts/prepare-ios-signing.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 +SIGNING_DIR="$RUNNER_TEMP/prego-signing" +mkdir -p "$SIGNING_DIR" "$HOME/Library/MobileDevice/Provisioning Profiles" +export SIGNING_DIR +python3 - <<'PY' +import base64, os +from pathlib import Path +root = Path(os.environ['SIGNING_DIR']) +for key, name in [('IOS_CERTIFICATE_BASE64', 'distribution.p12'), ('IOS_PROFILE_BASE64', 'profile.mobileprovision')]: + root.joinpath(name).write_bytes(base64.b64decode(os.environ[key], validate=True)) +root.joinpath('AuthKey_' + os.environ['ASC_KEY_ID'] + '.p8').write_text(os.environ['ASC_PRIVATE_KEY']) +PY +security create-keychain -p "$IOS_CERTIFICATE_PASSWORD" "$SIGNING_DIR/build.keychain-db" +security set-keychain-settings -lut 21600 "$SIGNING_DIR/build.keychain-db" +security unlock-keychain -p "$IOS_CERTIFICATE_PASSWORD" "$SIGNING_DIR/build.keychain-db" +security import "$SIGNING_DIR/distribution.p12" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$SIGNING_DIR/build.keychain-db" +security list-keychains -d user -s "$SIGNING_DIR/build.keychain-db" login.keychain-db +security set-key-partition-list -S apple-tool:,apple: -k "$IOS_CERTIFICATE_PASSWORD" "$SIGNING_DIR/build.keychain-db" +security cms -D -i "$SIGNING_DIR/profile.mobileprovision" > "$SIGNING_DIR/profile.plist" +python3 - <<'PY' +import datetime, os, plistlib, shutil +from pathlib import Path +root = Path(os.environ['SIGNING_DIR']) +profile = plistlib.loads(root.joinpath('profile.plist').read_bytes()) +team = os.environ['APPLE_TEAM_ID'] +expected = team + '.com.prego.app' +if profile.get('TeamIdentifier') != [team] or profile.get('Entitlements', {}).get('application-identifier') != expected: + raise SystemExit('Profile team or bundle ID does not match com.prego.app. Configure the correct distribution profile.') +if profile.get('ExpirationDate', datetime.datetime.min) <= datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None): + raise SystemExit('Distribution profile has expired.') +if profile.get('ProvisionedDevices') or profile.get('ProvisionsAllDevices') or profile.get('Entitlements', {}).get('get-task-allow'): + raise SystemExit('Use an App Store distribution profile, not development, ad hoc or enterprise.') +uuid = profile['UUID'] +shutil.copyfile(root.joinpath('profile.mobileprovision'), Path.home() / 'Library/MobileDevice/Provisioning Profiles' / (uuid + '.mobileprovision')) +root.joinpath('profile-uuid').write_text(uuid) +with root.joinpath('ExportOptions.plist').open('wb') as out: + plistlib.dump({'method': 'app-store-connect', 'teamID': team, 'signingStyle': 'manual', + 'manageAppVersionAndBuildNumber': False, 'provisioningProfiles': {'com.prego.app': uuid}}, out) +PY diff --git a/scripts/test/beta-config.test.mjs b/scripts/test/beta-config.test.mjs new file mode 100644 index 0000000..32dbe73 --- /dev/null +++ b/scripts/test/beta-config.test.mjs @@ -0,0 +1,33 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +const base = { PATH: process.env.PATH, PREGO_VERSION_NAME: '1.0.0', PREGO_BUILD_NUMBER: '2' }; +const ios = { ...base, APPLE_TEAM_ID: 'ABCDEFGHIJ', APPLE_APP_ID: '123456', ASC_KEY_ID: 'TESTKEY', ASC_ISSUER_ID: 'fixture', ASC_PRIVATE_KEY: 'PRIVATE-FIXTURE-NOT-A-REAL-KEY', IOS_CERTIFICATE_BASE64: 'fixture', IOS_CERTIFICATE_PASSWORD: 'fixture', IOS_PROFILE_BASE64: 'fixture' }; +const run = (platform, env) => spawnSync(process.execPath, ['scripts/check-beta-config.mjs', platform], { env, encoding: 'utf8' }); +test('missing credentials fail before signing or upload and report setting names', () => { + const result = run('ios', base); + assert.equal(result.status, 1); + assert.match(result.stderr, /APPLE_TEAM_ID/); + assert.match(result.stderr, /IOS_PROFILE_BASE64/); +}); +test('presence checks do not echo credential contents', () => { + const result = run('ios', ios); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout + result.stderr, /PRIVATE-FIXTURE/); +}); +test('rejects invalid version, build number and team identifiers', () => { + for (const change of [{ PREGO_BUILD_NUMBER: '0' }, { PREGO_BUILD_NUMBER: '2100000001' }, { PREGO_VERSION_NAME: '1.0; echo bad' }, { APPLE_TEAM_ID: 'wrong' }]) { + assert.notEqual(run('ios', { ...ios, ...change }).status, 0); + } +}); +test('malformed service account cannot expose JSON or private-key fragments', () => { + const result = run('android', { ...base, ANDROID_KEYSTORE_BASE64: 'fixture', PREGO_KEYSTORE_PASSWORD: 'fixture', PREGO_KEY_ALIAS: 'fixture', PREGO_KEY_PASSWORD: 'fixture', PLAY_SERVICE_ACCOUNT_JSON: '{PRIVATE-FRAGMENT' }); + assert.notEqual(result.status, 0); + assert.doesNotMatch(result.stderr, /PRIVATE-FRAGMENT/); +}); + +test('numeric App Store ID is optional because upload resolves the signed bundle identifier', () => { + const { APPLE_APP_ID, ...withoutAppId } = ios; + assert.equal(APPLE_APP_ID, '123456'); + assert.equal(run('ios', withoutAppId).status, 0); +}); diff --git a/scripts/test/play-upload.test.py b/scripts/test/play-upload.test.py new file mode 100644 index 0000000..70dba78 --- /dev/null +++ b/scripts/test/play-upload.test.py @@ -0,0 +1,64 @@ +"""Offline contract checks; no store account or network is used.""" +import importlib.util +import contextlib +import io +from pathlib import Path +import sys +import types +import unittest +from unittest.mock import patch + +# Minimal provider module fixtures let tests run without installing cloud SDKs. +modules = ['google', 'google.oauth2', 'google.oauth2.service_account', 'google.auth', 'google.auth.transport', 'google.auth.transport.requests'] +for name in modules: + sys.modules.setdefault(name, types.ModuleType(name)) +sys.modules['google.oauth2.service_account'].Credentials = type('Credentials', (), {'from_service_account_file': staticmethod(lambda *args, **kwargs: object())}) +sys.modules['google.auth.transport.requests'].AuthorizedSession = object +spec = importlib.util.spec_from_file_location('upload_play', Path(__file__).parents[1] / 'upload-play-internal.py') +upload = importlib.util.module_from_spec(spec) +spec.loader.exec_module(upload) + +class Reply: + ok = True + content = b'{}' + status_code = 200 + def __init__(self, data): self.data = data + def json(self): return self.data + +class Client: + def __init__(self, code=2): + self.calls = [] + self.deleted = False + self.code = code + def request(self, method, url, **kwargs): + self.calls.append((method, url, kwargs)) + return Reply({'versionCode': self.code} if '/bundles?' in url else {'id': 'test-edit'}) + def delete(self, *args, **kwargs): self.deleted = True + def close(self): pass + +class UploadTests(unittest.TestCase): + def invoke(self, client, package='com.prego.app'): + args = ['upload-play', '--credentials', 'fixture.json', '--bundle', __file__, '--package', package, '--build-number', '2', '--version', '1.0.0'] + with patch.object(sys, 'argv', args), patch.object(upload, 'AuthorizedSession', return_value=client), contextlib.redirect_stdout(io.StringIO()): + upload.main() + def test_internal_track_only_and_validate_before_commit(self): + client = Client() + self.invoke(client) + track = next(call for call in client.calls if '/tracks/' in call[1]) + self.assertTrue(track[1].endswith('/tracks/internal')) + self.assertEqual(track[2]['json']['track'], 'internal') + self.assertEqual(track[2]['json']['releases'][0]['versionCodes'], ['2']) + self.assertTrue(client.calls[-2][1].endswith(':validate')) + self.assertTrue(client.calls[-1][1].endswith(':commit')) + self.assertFalse(client.deleted) + def test_mismatched_build_deletes_edit_and_never_publishes(self): + client = Client(code=9) + with self.assertRaises(RuntimeError): self.invoke(client) + self.assertTrue(client.deleted) + self.assertFalse(any('/tracks/' in url or url.endswith(':commit') for _, url, _ in client.calls)) + def test_other_package_is_rejected_before_network(self): + client = Client() + with self.assertRaises(SystemExit): self.invoke(client, package='other.app') + self.assertEqual(client.calls, []) + +if __name__ == '__main__': unittest.main() diff --git a/scripts/test/server.test.mjs b/scripts/test/server.test.mjs new file mode 100644 index 0000000..04052f4 --- /dev/null +++ b/scripts/test/server.test.mjs @@ -0,0 +1,36 @@ +import { before, after, test } from 'node:test'; +import assert from 'node:assert/strict'; +import app from '../../server/app.js'; +let server; +let url; +before(async () => { + server = app.listen(0, '127.0.0.1'); + await new Promise(resolve => server.once('listening', resolve)); + url = `http://127.0.0.1:${server.address().port}`; +}); +after(() => new Promise(resolve => server.close(resolve))); +test('health check is minimal, does not cache or expose server technology', async () => { + const response = await fetch(`${url}/api/health`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { status: 'ok' }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.get('x-powered-by'), null); +}); +test('old payment and customer portal routes never charge or disclose customer access', async () => { + for (const path of ['create-checkout-session', 'create-portal-session', 'webhooks/stripe']) { + const response = await fetch(`${url}/api/${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"customerId":"cus_other_account"}' }); + assert.equal(response.status, 410); + assert.deepEqual(await response.json(), { error: 'Legacy web billing is unavailable.' }); + } +}); +test('security policy forbids third-party framing and script execution', async () => { + const response = await fetch(`${url}/api/health`); + assert.match(response.headers.get('content-security-policy'), /script-src 'self'/); + assert.match(response.headers.get('content-security-policy'), /frame-ancestors 'none'/); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); +}); +test('unknown API paths return JSON 404 instead of the app shell', async () => { + const response = await fetch(`${url}/api/unknown`); + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { error: 'Not found' }); +}); diff --git a/scripts/upload-play-internal.py b/scripts/upload-play-internal.py new file mode 100644 index 0000000..d545f2b --- /dev/null +++ b/scripts/upload-play-internal.py @@ -0,0 +1,58 @@ +"""Upload only to the Google Play internal track. Never touches production. +Requires google-auth and requests; credentials are supplied by a temporary file. +""" +import argparse +from pathlib import Path +from google.oauth2 import service_account +from google.auth.transport.requests import AuthorizedSession + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--credentials', required=True) + parser.add_argument('--bundle', required=True) + parser.add_argument('--package', default='com.prego.app') + parser.add_argument('--build-number', required=True, type=int) + parser.add_argument('--version', required=True) + args = parser.parse_args() + if args.package != 'com.prego.app': + raise SystemExit('Package differs from this repository. Verify bundle IDs before upload.') + credentials = service_account.Credentials.from_service_account_file( + args.credentials, scopes=['https://www.googleapis.com/auth/androidpublisher']) + client = AuthorizedSession(credentials) + base = f'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{args.package}/edits' + + def request(method, url, **kwargs): + response = client.request(method, url, timeout=300, **kwargs) + if not response.ok: + # Do not echo service-account contents, OAuth tokens or request bodies. + raise RuntimeError(f'Google Play returned HTTP {response.status_code}. Check package registration, app setup, upload key and service-account permissions.') + return response.json() if response.content else {} + + edit_id = request('POST', base, json={})['id'] + committed = False + try: + with Path(args.bundle).open('rb') as bundle: + upload = request('POST', f'https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/{args.package}/edits/{edit_id}/bundles?uploadType=media', + data=bundle, headers={'Content-Type': 'application/octet-stream'}) + code = upload['versionCode'] + if int(code) != args.build_number: + raise RuntimeError('Uploaded bundle version does not match the requested build.') + request('PUT', f'{base}/{edit_id}/tracks/internal', json={ + 'track': 'internal', 'releases': [{ + 'name': f'Prego usability beta {args.version} ({code})', + 'versionCodes': [str(code)], 'status': 'completed', + 'releaseNotes': [{'language': 'en-CA', 'text': 'Usability beta: explore navigation, accessibility, offline use and private data controls. Guided workouts and purchases remain unavailable pending review.'}] + }]}) + request('POST', f'{base}/{edit_id}:validate') + request('POST', f'{base}/{edit_id}:commit') + committed = True + print(f'Committed build {code} to Google Play internal testing. Verify processing and tester access in Play Console.') + finally: + if not committed: + client.delete(f'{base}/{edit_id}', timeout=30) + client.close() + + +if __name__ == '__main__': + main() diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..e9ecaa9 --- /dev/null +++ b/server/app.js @@ -0,0 +1,36 @@ +import express from 'express'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const app = express(); +const root = dirname(fileURLToPath(import.meta.url)); +app.disable('x-powered-by'); +app.use((req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"); + if (process.env.NODE_ENV === 'production') res.setHeader('Strict-Transport-Security', 'max-age=31536000'); + next(); +}); +// Legacy web billing cannot accept money or grant access. Mobile billing uses +// the configured native stores, never an unauthenticated customer portal. +for (const path of ['/api/create-checkout-session', '/api/create-portal-session', '/api/webhooks/stripe']) { + app.post(path, (req, res) => res.status(410).json({ error: 'Legacy web billing is unavailable.' })); +} +app.get('/api/health', (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.json({ status: 'ok' }); +}); +app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' })); +app.use(express.static(join(root, '../dist'), { + setHeaders(res, path) { + res.setHeader('Cache-Control', path.includes('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache'); + }, +})); +app.get('*', (req, res) => { + res.setHeader('Cache-Control', 'no-cache'); + res.sendFile(join(root, '../dist/index.html')); +}); +export default app; diff --git a/server/index.js b/server/index.js index acdcfb2..8604ebe 100644 --- a/server/index.js +++ b/server/index.js @@ -1,166 +1,3 @@ -import express from 'express'; -import Stripe from 'stripe'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const app = express(); - -// Validate required environment variables -if (!process.env.STRIPE_SECRET_KEY) { - console.warn('Warning: STRIPE_SECRET_KEY not set. Stripe features will be disabled.'); -} -if (!process.env.STRIPE_WEBHOOK_SECRET) { - console.warn('Warning: STRIPE_WEBHOOK_SECRET not set. Webhook verification will fail.'); -} - -const stripe = process.env.STRIPE_SECRET_KEY ? new Stripe(process.env.STRIPE_SECRET_KEY) : null; - -const APP_URL = process.env.APP_URL || 'https://prego.ashbi.ca'; - -// Security headers -app.use((req, res, next) => { - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('X-XSS-Protection', '1; mode=block'); - res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); - next(); -}); - -// Simple in-memory rate limiter -const rateLimitMap = new Map(); - -function rateLimit(windowMs = 60000, maxRequests = 10) { - return (req, res, next) => { - // Try x-forwarded-for for proxies, fallback to socket.remoteAddress, then req.ip - const clientIp = (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket?.remoteAddress || req.ip || 'unknown'; - const key = clientIp; - const now = Date.now(); - const windowStart = now - windowMs; - - let record = rateLimitMap.get(key); - if (!record) { - record = []; - rateLimitMap.set(key, record); - } - - // Remove expired entries - while (record.length > 0 && record[0] < windowStart) { - record.shift(); - } - - if (record.length >= maxRequests) { - return res.status(429).json({ error: 'Too many requests. Please try again later.' }); - } - - record.push(now); - next(); - }; -} - -// Webhook needs raw body -app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { - if (!stripe) return res.status(503).json({ error: 'Stripe not configured' }); - const sig = req.headers['stripe-signature']; - try { - const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET); - switch (event.type) { - case 'checkout.session.completed': - console.log('Payment processed successfully'); - break; - case 'customer.subscription.updated': - case 'customer.subscription.deleted': - console.log('Subscription status changed'); - break; - } - res.json({ received: true }); - } catch (err) { - console.error('Webhook signature verification failed'); - res.status(400).json({ error: 'Webhook signature verification failed' }); - } -}); - -app.use(express.json()); -app.use(express.static(join(__dirname, '../dist'))); - -// Apply rate limiting to payment endpoints -const paymentRateLimit = rateLimit(60000, 10); - -// Create checkout session -app.post('/api/create-checkout-session', paymentRateLimit, async (req, res) => { - if (!stripe) return res.status(503).json({ error: 'Stripe not configured' }); - try { - const session = await stripe.checkout.sessions.create({ - payment_method_types: ['card'], - line_items: [{ - price_data: { - currency: 'usd', - product_data: { - name: 'Prego Premium', - description: 'Full access to all workouts, custom plans, and progress tracking' - }, - unit_amount: 499, - recurring: { interval: 'month' } - }, - quantity: 1 - }], - mode: 'subscription', - success_url: `${APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${APP_URL}/pricing`, - allow_promotion_codes: true - }); - res.json({ url: session.url }); - } catch (error) { - console.error('Checkout session creation failed:', error.message); - res.status(500).json({ error: 'Failed to create checkout session' }); - } -}); - -// Customer portal -app.post('/api/create-portal-session', paymentRateLimit, async (req, res) => { - if (!stripe) return res.status(503).json({ error: 'Stripe not configured' }); - - const { customerId } = req.body; - if (!customerId || typeof customerId !== 'string' || !customerId.startsWith('cus_')) { - return res.status(400).json({ error: 'Invalid customer ID' }); - } - - try { - const session = await stripe.billingPortal.sessions.create({ - customer: customerId, - return_url: `${APP_URL}/settings` - }); - res.json({ url: session.url }); - } catch (error) { - console.error('Portal session creation failed:', error.message); - res.status(500).json({ error: 'Failed to create portal session' }); - } -}); - -// Health check -app.get('/api/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); - -// SPA fallback -app.get('*', (req, res) => { - res.sendFile(join(__dirname, '../dist/index.html')); -}); - -const PORT = process.env.PORT || 3000; -app.listen(PORT, '0.0.0.0', () => { - console.log(`Prego server running on port ${PORT}`); -}); - -// TTL cleanup for rate limiter — prevents memory leaks -const RATE_LIMITER_TTL = 10 * 60 * 1000; // 10 min -setInterval(() => { - const now = Date.now(); - for (const [key, record] of rateLimitMap) { - if (record.length === 0 || record[record.length - 1] < now - RATE_LIMITER_TTL) { - rateLimitMap.delete(key); - } - } -}, 5 * 60 * 1000); // cleanup every 5 min +import app from './app.js'; +const server = app.listen(process.env.PORT || 3000, '0.0.0.0'); +for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => server.close(() => process.exit(0))); diff --git a/src/app/routes/layouts.tsx b/src/app/routes/layouts.tsx index e081132..fc21539 100644 --- a/src/app/routes/layouts.tsx +++ b/src/app/routes/layouts.tsx @@ -1,3 +1,4 @@ +import { StorageNotice } from '../../components/StorageNotice'; import { Outlet } from 'react-router-dom'; import { Header } from '../../shared/components/layout/Header'; import { BottomNav } from '../../shared/components/layout/BottomNav'; @@ -5,7 +6,9 @@ import { BottomNav } from '../../shared/components/layout/BottomNav'; export function RootLayout() { return ( diff --git a/src/app/routes/lazyPages.tsx b/src/app/routes/lazyPages.tsx index cf3f7b6..7eae0e6 100644 --- a/src/app/routes/lazyPages.tsx +++ b/src/app/routes/lazyPages.tsx @@ -16,3 +16,5 @@ export const OnboardingPage = lazy(() => import('../../pages/OnboardingPage').th export const PricingPage = lazy(() => import('../../pages/PricingPage').then(m => ({ default: m.PricingPage }))); export const SuccessPage = lazy(() => import('../../pages/SuccessPage').then(m => ({ default: m.SuccessPage }))); export const NotFoundPage = lazy(() => import('../../pages/NotFoundPage').then(m => ({ default: m.NotFoundPage }))); + +export const LearnPage = lazy(() => import('../../pages/LearnPage').then(m => ({ default: m.LearnPage }))); diff --git a/src/app/routes/router.tsx b/src/app/routes/router.tsx index 7960954..4ccdc0b 100644 --- a/src/app/routes/router.tsx +++ b/src/app/routes/router.tsx @@ -6,6 +6,7 @@ import { ExerciseDetailPage, ExercisesPage, HomePage, + LearnPage, NotFoundPage, OnboardingPage, PricingPage, @@ -24,6 +25,7 @@ export const router = createBrowserRouter([ element: , children: [ { index: true, element: withSuspense(HomePage) }, + { path: 'learn', element: withSuspense(LearnPage) }, { path: 'exercises', element: withSuspense(ExercisesPage) }, { path: 'exercises/:exerciseId', element: withSuspense(ExerciseDetailPage) }, { path: 'workouts', element: withSuspense(WorkoutsPage) }, diff --git a/src/components/DataControls.tsx b/src/components/DataControls.tsx new file mode 100644 index 0000000..5a9cd0b --- /dev/null +++ b/src/components/DataControls.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import { + exportBackup, + parseBackup, + restoreBackup, + type Backup, +} from "../lib/dataControls"; +import { Button } from "../shared/components/ui/Button"; +import { Modal } from "../shared/components/ui/Modal"; +export function DataControls() { + const [message, setMessage] = useState(""); + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + return ( +
+

+ Your data, your choice +

+

+ Save a backup before changing devices. Exported files contain private + profile and workout information. Keep them somewhere secure. +

+
+ + +
+

+ {message} +

+ setPending(null)} + title="Replace your local data?" + > +

+ This replaces your profile and history with{" "} + {pending?.completedSessions.length ?? 0} saved sessions. Export your + current data first if you want to keep it. +

+ +
+
+ ); +} diff --git a/src/components/Pricing.tsx b/src/components/Pricing.tsx index 31f1fdd..4e39545 100644 --- a/src/components/Pricing.tsx +++ b/src/components/Pricing.tsx @@ -1,118 +1,147 @@ -import { useState } from 'react'; -import { Check, Crown, Dumbbell, Heart, AlertCircle } from 'lucide-react'; - +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Check, Leaf } from "lucide-react"; +import type { PurchasesPackage } from "@revenuecat/purchases-capacitor"; +import { + billingConfigured, + loadPregnancyPass, + purchasePregnancyPass, + restorePregnancyPass, +} from "../lib/billing"; +import { Button } from "../shared/components/ui/Button"; export default function Pricing() { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const handleCheckout = async () => { - setLoading(true); - setError(null); + const [offer, setOffer] = useState(null); + const [message, setMessage] = useState(""); + const [busy, setBusy] = useState(false); + const [owned, setOwned] = useState(false); + useEffect(() => { + let active = true; + if (billingConfigured()) + void loadPregnancyPass() + .then((value) => { + if (active) setOffer(value); + }) + .catch(() => { + if (active) + setMessage("The store could not load. Try again when connected."); + }); + return () => { + active = false; + }; + }, []); + const restore = async () => { + setBusy(true); try { - const response = await fetch('/api/create-checkout-session', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - if (!response.ok) { - throw new Error('Unable to start checkout. Please try again.'); - } - const data = await response.json(); - if (data.url) { - // Validate redirect URL is Stripe or same-origin - const targetUrl = new URL(data.url); - if (targetUrl.hostname.endsWith('stripe.com') || targetUrl.origin === window.location.origin) { - window.location.href = data.url; - } else { - throw new Error('Unexpected redirect URL.'); - } - } else { - throw new Error('No checkout URL received.'); - } - } catch (err) { - const message = err instanceof Error ? err.message : 'Something went wrong. Please try again.'; - setError(message); + const pass = await restorePregnancyPass(); + setOwned(pass); + setMessage( + pass + ? "Your Pregnancy Pass has been restored." + : "No Pregnancy Pass was found for this store account.", + ); + } catch (error) { + setMessage( + error instanceof Error + ? error.message + : "Restoration could not complete. Please try again.", + ); } finally { - setLoading(false); + setBusy(false); } }; - return ( -
-
-
- -

Your Pregnancy Fitness Journey

-

Safe, guided workouts for every trimester

-
- -
- {/* Free */} -
- -

Free

-
- $0 - /forever -
-
    - {['5 basic exercises', '1 workout plan', 'Timer', 'Basic progress tracking'].map((f, i) => ( -
  • - {f} -
  • - ))} -
- -
- - {/* Premium */} -
-
- Recommended -
- -

Premium

-
- $4.99 - /month -
-
    - {[ - 'All 12+ exercises', - '8 workout programs', - 'Custom workout plans', - 'Audio coaching cues', - 'Full progress history', - 'Achievement badges', - 'Priority support' - ].map((f, i) => ( -
  • - {f} -
  • - ))} -
- -
-
- - {error && ( -
- -

{error}

-
- )} - -

- Cancel anytime. All exercises reviewed by certified prenatal fitness experts. -

+
+
+

+ {message} +

+ ); } diff --git a/src/components/StorageNotice.tsx b/src/components/StorageNotice.tsx new file mode 100644 index 0000000..9700d63 --- /dev/null +++ b/src/components/StorageNotice.tsx @@ -0,0 +1,16 @@ +import { useSyncExternalStore } from "react"; +import { storageHealth } from "../lib/storage"; +export function StorageNotice() { + const message = useSyncExternalStore( + storageHealth.subscribe, + storageHealth.getSnapshot, + ); + return message ? ( +
+ {message} +
+ ) : null; +} diff --git a/src/hooks/usePregnancyStage.ts b/src/hooks/usePregnancyStage.ts new file mode 100644 index 0000000..720716a --- /dev/null +++ b/src/hooks/usePregnancyStage.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; +import { useUserStore } from "../stores"; +import { calculatePregnancyInfo } from "../stores/userStore"; +export function usePregnancyStage() { + const dueDate = useUserStore((state) => state.profile?.dueDate); + const [today, setToday] = useState(() => new Date()); + useEffect(() => { + const refresh = () => setToday(new Date()); + const timer = window.setInterval(refresh, 60000); + document.addEventListener("visibilitychange", refresh); + window.addEventListener("focus", refresh); + return () => { + clearInterval(timer); + document.removeEventListener("visibilitychange", refresh); + window.removeEventListener("focus", refresh); + }; + }, []); + return calculatePregnancyInfo(dueDate ?? new Date(NaN), today); +} diff --git a/src/hooks/useTimer.ts b/src/hooks/useTimer.ts index e2d236b..dbb181d 100644 --- a/src/hooks/useTimer.ts +++ b/src/hooks/useTimer.ts @@ -1,185 +1,279 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; -import type { TimerState, TimerConfig } from '../types'; +import { useState, useCallback, useRef, useEffect } from "react"; +import { App } from "@capacitor/app"; +import { Capacitor } from "@capacitor/core"; +import type { TimerState, TimerConfig } from "../types"; +import { durableStorage } from "../lib/storage"; +const initial = (config: TimerConfig): TimerState => ({ + phase: "countdown", + totalSeconds: config.countdownSeconds, + remainingSeconds: config.countdownSeconds, + currentExerciseIndex: 0, + totalExercises: config.exercises.length, + isRunning: false, + isPaused: false, +}); export function useTimer(config: TimerConfig) { - const [state, setState] = useState(() => ({ - phase: 'countdown', - totalSeconds: config.countdownSeconds, - remainingSeconds: config.countdownSeconds, - currentExerciseIndex: 0, - totalExercises: config.exercises.length, - isRunning: false, - isPaused: false - })); - - const intervalRef = useRef(null); - const lastTickRef = useRef(0); - - // Store config in a ref so timer callbacks always see the latest - // without causing interval restarts when callbacks change + const [state, setState] = useState(() => initial(config)); + const [isRestoring, setIsRestoring] = useState(!!config.persistenceKey); + const stateRef = useRef(state); const configRef = useRef(config); + const lastTick = useRef(0); + const restoredKey = useRef(undefined); + const lastSaved = useRef(""); + const persist = useCallback((next: TimerState, force = false) => { + const current = configRef.current; + if (!current.persistenceKey) return; + const signature = `${current.persistenceKey}:${next.phase}:${next.currentExerciseIndex}:${Math.ceil(next.remainingSeconds)}:${next.isPaused}:${next.isRunning}`; + if (!force && signature === lastSaved.current) return; + lastSaved.current = signature; + void durableStorage.setItem( + "prego-active-timer", + JSON.stringify({ + version: 1, + key: current.persistenceKey, + signature: current.exercises + .map( + (ex) => `${ex.exerciseId}:${ex.durationSeconds}:${ex.restSeconds}`, + ) + .join("|"), + state: { ...next, isPaused: next.phase !== "complete" }, + }), + ); + }, []); + const commit = useCallback( + (next: TimerState) => { + stateRef.current = next; + setState(next); + persist(next); + }, + [persist], + ); useEffect(() => { configRef.current = config; }); - - const clearTimer = useCallback(() => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }, []); - - const moveToNextExercise = useCallback( - (currentState: TimerState, exercises: TimerConfig['exercises']): TimerState => { - const nextIndex = currentState.currentExerciseIndex + 1; - - if (nextIndex >= exercises.length) { - configRef.current.onPhaseChange?.('complete'); - configRef.current.onWorkoutComplete?.(); - return { - ...currentState, - phase: 'complete', + useEffect(() => { + if (!config.persistenceKey) return; + let canceled = false; + const restore = async () => { + try { + const text = await durableStorage.getItem("prego-active-timer"); + if (!text || canceled) return; + const saved = JSON.parse(text); + const candidate: TimerState = saved.state; + const signature = config.exercises + .map( + (ex) => `${ex.exerciseId}:${ex.durationSeconds}:${ex.restSeconds}`, + ) + .join("|"); + const validPhase = [ + "countdown", + "exercise", + "rest", + "complete", + ].includes(candidate?.phase); + const index = candidate?.currentExerciseIndex; + const exercise = config.exercises[index]; + const expectedDuration = + candidate?.phase === "countdown" + ? config.countdownSeconds + : candidate?.phase === "rest" + ? exercise?.restSeconds + : candidate?.phase === "exercise" + ? exercise?.durationSeconds + : candidate?.totalSeconds; + if ( + saved.version === 1 && + saved.key === config.persistenceKey && + saved.signature === signature && + validPhase && + Number.isInteger(index) && + index >= 0 && + index < config.exercises.length && + candidate.totalExercises === config.exercises.length && + candidate.totalSeconds === expectedDuration && + Number.isFinite(candidate.remainingSeconds) && + candidate.remainingSeconds >= 0 && + candidate.remainingSeconds <= candidate.totalSeconds + ) { + commit({ + ...candidate, + isRunning: false, + isPaused: candidate.phase !== "complete", + }); + } + } catch { + /* Storage reports failures; preserve current timer state. */ + } finally { + if (!canceled) { + restoredKey.current = config.persistenceKey; + setIsRestoring(false); + } + } + }; + void restore(); + return () => { + canceled = true; + }; + // The identity changes only when a new session starts, never on every tick. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.persistenceKey, commit]); + const advance = useCallback( + (skipped = false) => { + const current = stateRef.current; + const cfg = configRef.current; + if (current.phase === "complete") return; + const exercise = cfg.exercises[current.currentExerciseIndex]; + let next = current; + if (!exercise) + next = { + ...current, + phase: "complete", + isRunning: false, + isPaused: false, remainingSeconds: 0, - isRunning: false }; - } - - const nextExercise = exercises[nextIndex]; - configRef.current.onPhaseChange?.('exercise'); - - return { - ...currentState, - phase: 'exercise', - currentExerciseIndex: nextIndex, - totalSeconds: nextExercise.durationSeconds, - remainingSeconds: nextExercise.durationSeconds - }; - }, - [] - ); - - const handlePhaseComplete = useCallback( - (currentState: TimerState): TimerState => { - const { phase, currentExerciseIndex } = currentState; - const exercises = configRef.current.exercises; - - switch (phase) { - case 'countdown': { - const firstExercise = exercises[0]; - configRef.current.onPhaseChange?.('exercise'); - return { - ...currentState, - phase: 'exercise', - totalSeconds: firstExercise.durationSeconds, - remainingSeconds: firstExercise.durationSeconds - }; - } - - case 'exercise': { - configRef.current.onExerciseComplete?.(currentExerciseIndex); - const currentExercise = exercises[currentExerciseIndex]; - - if (currentExercise.restSeconds > 0) { - configRef.current.onPhaseChange?.('rest'); - return { - ...currentState, - phase: 'rest', - totalSeconds: currentExercise.restSeconds, - remainingSeconds: currentExercise.restSeconds + else if (current.phase === "countdown") + next = { + ...current, + phase: "exercise", + totalSeconds: exercise.durationSeconds, + remainingSeconds: exercise.durationSeconds, + }; + else if (current.phase === "exercise" && exercise.restSeconds > 0) + next = { + ...current, + phase: "rest", + totalSeconds: exercise.restSeconds, + remainingSeconds: exercise.restSeconds, + }; + else { + const nextIndex = current.currentExerciseIndex + 1; + const nextExercise = cfg.exercises[nextIndex]; + next = nextExercise + ? { + ...current, + phase: "exercise", + currentExerciseIndex: nextIndex, + totalSeconds: nextExercise.durationSeconds, + remainingSeconds: nextExercise.durationSeconds, + } + : { + ...current, + phase: "complete", + isRunning: false, + isPaused: false, + remainingSeconds: 0, }; - } - - return moveToNextExercise(currentState, exercises); - } - - case 'rest': { - return moveToNextExercise(currentState, exercises); - } - - default: - return currentState; } + lastTick.current = performance.now(); + commit(next); + // Effects happen after the committed transition, never in a React updater. + if (current.phase === "exercise" && exercise) + cfg.onExerciseComplete?.( + current.currentExerciseIndex, + skipped, + skipped + ? Math.max(0, exercise.durationSeconds - current.remainingSeconds) + : exercise.durationSeconds, + ); + if ( + next.phase !== current.phase || + next.currentExerciseIndex !== current.currentExerciseIndex + ) + cfg.onPhaseChange?.(next.phase); + if (next.phase === "complete") cfg.onWorkoutComplete?.(); }, - [moveToNextExercise] + [commit], ); - - // Timer tick — dependencies are stable (no config object) + const pause = useCallback(() => { + commit({ ...stateRef.current, isPaused: true }); + }, [commit]); useEffect(() => { - if (!state.isRunning || state.isPaused) { - clearTimer(); - return; - } - - lastTickRef.current = Date.now(); - - intervalRef.current = window.setInterval(() => { - const now = Date.now(); - const elapsed = (now - lastTickRef.current) / 1000; - lastTickRef.current = now; - - setState((prev) => { - if (!prev.isRunning || prev.isPaused) return prev; - - const newRemaining = Math.max(0, prev.remainingSeconds - elapsed); - - if (newRemaining <= 0) { - return handlePhaseComplete(prev); - } - - return { ...prev, remainingSeconds: newRemaining }; + const pauseIfHidden = () => { + if (document.visibilityState === "hidden" && stateRef.current.isRunning) + pause(); + }; + document.addEventListener("visibilitychange", pauseIfHidden); + window.addEventListener("pagehide", pause); + let disposed = false; + let remove: (() => Promise) | undefined; + if (Capacitor.isNativePlatform()) + void App.addListener("appStateChange", ({ isActive }) => { + if (!isActive && stateRef.current.isRunning) pause(); + }).then((listener) => { + if (disposed) void listener.remove(); + else remove = () => listener.remove(); }); + return () => { + disposed = true; + document.removeEventListener("visibilitychange", pauseIfHidden); + window.removeEventListener("pagehide", pause); + void remove?.(); + persist(stateRef.current, true); + }; + }, [pause, persist]); + useEffect(() => { + if (!state.isRunning || state.isPaused || isRestoring) return; + lastTick.current = performance.now(); + const id = window.setInterval(() => { + const current = stateRef.current; + if (!current.isRunning || current.isPaused) return; + const now = performance.now(); + const elapsed = (now - lastTick.current) / 1000; + lastTick.current = now; + // A stalled JS thread/background must never consume a whole movement. + if (document.visibilityState === "hidden" || elapsed > 2) { + pause(); + return; + } + const remainingSeconds = Math.max(0, current.remainingSeconds - elapsed); + if (remainingSeconds === 0) advance(); + else commit({ ...current, remainingSeconds }); }, 100); - - return clearTimer; - }, [state.isRunning, state.isPaused, handlePhaseComplete, clearTimer]); - + return () => clearInterval(id); + }, [state.isRunning, state.isPaused, isRestoring, advance, pause, commit]); const start = useCallback(() => { - setState((prev) => ({ ...prev, isRunning: true, isPaused: false })); - }, []); - - const pause = useCallback(() => { - setState((prev) => ({ ...prev, isPaused: true })); - }, []); - - const resume = useCallback(() => { - lastTickRef.current = Date.now(); - setState((prev) => ({ ...prev, isPaused: false })); - }, []); - - const skip = useCallback(() => { - setState((prev) => handlePhaseComplete(prev)); - }, [handlePhaseComplete]); - + if ( + isRestoring || + (configRef.current.persistenceKey && + restoredKey.current !== configRef.current.persistenceKey) || + stateRef.current.phase === "complete" + ) + return; + if (!configRef.current.exercises.length) { + advance(); + return; + } + lastTick.current = performance.now(); + commit({ ...stateRef.current, isRunning: true, isPaused: false }); + }, [isRestoring, advance, commit]); const reset = useCallback(() => { - clearTimer(); - const cfg = configRef.current; - setState({ - phase: 'countdown', - totalSeconds: cfg.countdownSeconds, - remainingSeconds: cfg.countdownSeconds, - currentExerciseIndex: 0, - totalExercises: cfg.exercises.length, - isRunning: false, - isPaused: false - }); - }, [clearTimer]); - + commit(initial(configRef.current)); + }, [commit]); const goToExercise = useCallback( (index: number) => { - const exercises = configRef.current.exercises; - if (index < 0 || index >= exercises.length) return; - - const exercise = exercises[index]; - setState((prev) => ({ - ...prev, - phase: 'exercise', + const exercise = configRef.current.exercises[index]; + if (!Number.isInteger(index) || !exercise) return; + lastTick.current = performance.now(); + commit({ + ...stateRef.current, + phase: "exercise", currentExerciseIndex: index, totalSeconds: exercise.durationSeconds, - remainingSeconds: exercise.durationSeconds - })); + remainingSeconds: exercise.durationSeconds, + }); }, - [] + [commit], ); - - return { state, start, pause, resume, skip, reset, goToExercise }; + return { + state, + isRestoring, + start, + pause, + resume: start, + skip: () => advance(true), + reset, + goToExercise, + }; } diff --git a/src/index.css b/src/index.css index 2d5dc84..8d4f4c9 100644 --- a/src/index.css +++ b/src/index.css @@ -2,32 +2,32 @@ @theme { /* Pregnancy-friendly, calming palette */ - --color-primary-50: #f0f9ff; - --color-primary-100: #e0f2fe; - --color-primary-200: #bae6fd; - --color-primary-300: #7dd3fc; - --color-primary-400: #38bdf8; - --color-primary-500: #0ea5e9; - --color-primary-600: #0284c7; - --color-primary-700: #0369a1; - --color-primary-800: #075985; - --color-primary-900: #0c4a6e; - - --color-accent-50: #fdf4ff; - --color-accent-100: #fae8ff; - --color-accent-200: #f5d0fe; - --color-accent-300: #f0abfc; - --color-accent-400: #e879f9; - --color-accent-500: #d946ef; - --color-accent-600: #c026d3; - --color-accent-700: #a21caf; - --color-accent-800: #86198f; - --color-accent-900: #701a75; - - --color-sage-50: #f6f7f6; - --color-sage-100: #e3e7e3; - --color-sage-200: #c7cfc7; - --color-sage-300: #a3b0a3; + --color-primary-50: #eef3ef; + --color-primary-100: #dce8df; + --color-primary-200: #c4d8ca; + --color-primary-300: #a8c5b5; + --color-primary-400: #7fa58f; + --color-primary-500: #527f66; + --color-primary-600: #3d6650; + --color-primary-700: #30533f; + --color-primary-800: #294737; + --color-primary-900: #22332c; + + --color-accent-50: #fcf4ef; + --color-accent-100: #f7e6db; + --color-accent-200: #f0d1bf; + --color-accent-300: #e8b29b; + --color-accent-400: #d59077; + --color-accent-500: #b9745a; + --color-accent-600: #a56048; + --color-accent-700: #85503f; + --color-accent-800: #6b4336; + --color-accent-900: #52352c; + + --color-sage-50: #faf8f4; + --color-sage-100: #edf0e9; + --color-sage-200: #d7ded3; + --color-sage-300: #b7c8ba; --color-sage-400: #7d8f7d; --color-sage-500: #627462; --color-sage-600: #4d5c4d; @@ -144,3 +144,37 @@ .pb-safe { padding-bottom: env(safe-area-inset-bottom, 0); } + +@layer base { + html { color-scheme: light; scroll-behavior: smooth; } + button, a, input, select, textarea { -webkit-tap-highlight-color: transparent; } + button { min-height: 44px; } + :focus-visible { outline: 3px solid var(--color-primary-600); outline-offset: 3px; } + input, textarea, select { font-size: 1rem; } + img, video { max-width: 100%; } +} +@utility pt-safe { padding-top: env(safe-area-inset-top, 0px); } +@utility pb-nav { padding-bottom: calc(6rem + env(safe-area-inset-bottom, 0px)); } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } +} +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --color-sage-50: #17221c; --color-sage-100: #233229; --color-sage-200: #37483d; --color-sage-300: #536d5b; + --color-white: #202e25; --color-gray-50: #233229; --color-gray-100: #2b3b31; --color-gray-200: #3a4e40; + --color-gray-300: #708875; --color-gray-400: #a6b6aa; --color-gray-500: #b1bfb5; --color-gray-600: #becac1; --color-gray-700: #d0d9d2; + --color-primary-50: #243a2c; --color-primary-100: #314b3a; --color-primary-700: #b5d1bd; --color-primary-800: #c5dacb; --color-primary-900: #dce8df; + --color-accent-700: #e8b29b; --color-gray-900: #f2f5ef; + } + .bg-primary-900 { background-color: #345b43; } + .text-white { color: #fff; } + .bg-gray-900 { background-color: #17221c; } + .bg-primary-900 .text-primary-900 { color: #fff; } +} + +@media (prefers-color-scheme: dark) { + .text-primary-600, .text-primary-500 { color: #c5dacb; } + .text-accent-600, .text-accent-500 { color: #e8b29b; } + .text-red-600, .text-red-700 { color: #ffb4a8; } +} diff --git a/src/lib/billing.ts b/src/lib/billing.ts new file mode 100644 index 0000000..e610704 --- /dev/null +++ b/src/lib/billing.ts @@ -0,0 +1,51 @@ +import { Capacitor } from "@capacitor/core"; +import { + Purchases, + type PurchasesPackage, + type CustomerInfo, +} from "@revenuecat/purchases-capacitor"; +const entitlement = "pregnancy_pass"; +let configured: Promise | undefined; +export function billingConfigured() { + if (import.meta.env.VITE_PURCHASES_ENABLED !== "true") return false; + const platform = Capacitor.getPlatform(); + return platform === "ios" + ? !!import.meta.env.VITE_REVENUECAT_IOS_KEY + : platform === "android" + ? !!import.meta.env.VITE_REVENUECAT_ANDROID_KEY + : false; +} +async function initialize() { + if (!billingConfigured()) + throw new Error("Store purchases are not available in this build."); + const apiKey = + Capacitor.getPlatform() === "ios" + ? import.meta.env.VITE_REVENUECAT_IOS_KEY + : import.meta.env.VITE_REVENUECAT_ANDROID_KEY; + configured ??= Purchases.configure({ apiKey }).catch((error) => { + configured = undefined; + throw error; + }); + await configured; +} +const hasPass = (customer: CustomerInfo) => + !!customer.entitlements.active[entitlement]?.isActive; +export async function loadPregnancyPass(): Promise { + await initialize(); + const offerings = await Purchases.getOfferings(); + // Only the configured lifetime package can be sold. Never silently offer a subscription. + return offerings.current?.lifetime ?? null; +} +export async function purchasePregnancyPass(aPackage: PurchasesPackage) { + await initialize(); + const result = await Purchases.purchasePackage({ aPackage }); + if (!hasPass(result.customerInfo)) + throw new Error( + "The store transaction completed but access has not been confirmed. Use Restore purchases or contact support.", + ); + return true; +} +export async function restorePregnancyPass() { + await initialize(); + return hasPass((await Purchases.restorePurchases()).customerInfo); +} diff --git a/src/lib/contentReview.ts b/src/lib/contentReview.ts new file mode 100644 index 0000000..12436c3 --- /dev/null +++ b/src/lib/contentReview.ts @@ -0,0 +1,52 @@ +import type { WorkoutPlan, UserProfile } from "../types"; +import { calculatePregnancyInfo } from "../stores/userStore"; +export interface ContentApproval { + status: "draft" | "approved" | "withdrawn"; + reviewer: string; + reviewedAt: string; + evidence: string[]; + version: number; +} +// Empty until qualified reviewers supply documented approvals. Never infer them. +export const exerciseApprovals: Record = {}; +export const workoutApprovals: Record = {}; +const approved = (record: ContentApproval | undefined) => + record?.status === "approved" && + record.reviewer.trim().length > 0 && + Number.isFinite(Date.parse(record.reviewedAt)) && + record.evidence.length > 0 && + record.version > 0; +export function workoutAvailability( + workout: WorkoutPlan, + profile: UserProfile | null, +): { available: boolean; reason: string } { + if ( + !approved(workoutApprovals[workout.id]) || + workout.exercises.some( + (exercise) => !approved(exerciseApprovals[exercise.exerciseId]), + ) + ) { + return { + available: false, + reason: + "This session is awaiting professional review. Guided workouts are not available yet.", + }; + } + if ( + !profile || + calculatePregnancyInfo(profile.dueDate).status !== "pregnancy" + ) { + return { + available: false, + reason: + "Update your pregnancy information before choosing a session. Prego never assumes a pregnancy outcome.", + }; + } + // Screening and contraindication contracts are not supplied yet; approval alone + // cannot grant clearance or enable selection. + return { + available: false, + reason: + "The professionally reviewed screening and eligibility policy is not configured yet.", + }; +} diff --git a/src/lib/dataControls.ts b/src/lib/dataControls.ts new file mode 100644 index 0000000..b10b859 --- /dev/null +++ b/src/lib/dataControls.ts @@ -0,0 +1,252 @@ +import { Capacitor } from "@capacitor/core"; +import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; +import { Share } from "@capacitor/share"; +import { useUserStore, useWorkoutSessionStore } from "../stores"; +import { flushStorage, storageHealth } from "./storage"; +import type { UserProfile, WorkoutSession } from "../types"; +import { replaceDataRecords } from "./storageTransaction"; + +export interface Backup { + format: "prego-backup"; + version: 1; + exportedAt: string; + profile: UserProfile | null; + isOnboarded: boolean; + completedSessions: WorkoutSession[]; +} +export function createBackup(): Backup { + const user = useUserStore.getState(); + return { + format: "prego-backup", + version: 1, + exportedAt: new Date().toISOString(), + profile: user.profile, + isOnboarded: user.isOnboarded, + completedSessions: useWorkoutSessionStore.getState().completedSessions, + }; +} +const record = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const date = (value: unknown) => + typeof value === "string" && Number.isFinite(Date.parse(value)); +const strings = (value: unknown): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === "string"); +export function parseBackup(text: string): Backup { + if (text.length > 5_000_000) + throw new Error("Backup is too large. Maximum size is 5 MB."); + const data: unknown = JSON.parse(text); + if ( + !record(data) || + data.format !== "prego-backup" || + data.version !== 1 || + typeof data.isOnboarded !== "boolean" || + !date(data.exportedAt) || + !Array.isArray(data.completedSessions) + ) + throw new Error("This is not a supported Prego backup."); + let profile: UserProfile | null = null; + if (data.profile !== null) { + const value = data.profile; + if ( + !record(value) || + typeof value.id !== "string" || + typeof value.displayName !== "string" || + value.displayName.length > 100 || + !date(value.dueDate) || + !strings(value.healthConditions) || + typeof value.doctorApproved !== "boolean" || + !record(value.preferences) + ) + throw new Error("The backup profile is invalid."); + const prefs = value.preferences; + if ( + typeof prefs.soundEnabled !== "boolean" || + typeof prefs.vibrationEnabled !== "boolean" || + !["low", "moderate", "high"].includes(String(prefs.preferredIntensity)) || + typeof prefs.preferredDuration !== "number" || + prefs.preferredDuration < 5 || + prefs.preferredDuration > 30 || + !Array.isArray(prefs.reminderDays) || + !prefs.reminderDays.every( + (day) => Number.isInteger(day) && day >= 0 && day <= 6, + ) + ) + throw new Error("The backup preferences are invalid."); + profile = { + id: value.id, + displayName: value.displayName, + dueDate: new Date(String(value.dueDate)), + currentWeek: 0, + currentTrimester: 1, + healthConditions: value.healthConditions, + doctorApproved: value.doctorApproved, + preferences: { + soundEnabled: prefs.soundEnabled, + vibrationEnabled: prefs.vibrationEnabled, + preferredIntensity: + prefs.preferredIntensity as UserProfile["preferences"]["preferredIntensity"], + preferredDuration: prefs.preferredDuration, + reminderDays: prefs.reminderDays, + ...(typeof prefs.reminderTime === "string" && + /^([01]\d|2[0-3]):[0-5]\d$/.test(prefs.reminderTime) + ? { reminderTime: prefs.reminderTime } + : {}), + }, + }; + } + if (data.isOnboarded && !profile) + throw new Error("The backup is missing its profile."); + const sessions = data.completedSessions.map( + (value: unknown): WorkoutSession => { + if ( + !record(value) || + typeof value.id !== "string" || + typeof value.workoutPlanId !== "string" || + !date(value.startedAt) || + !date(value.completedAt) || + !["completed", "abandoned"].includes(String(value.status)) || + typeof value.totalDurationSeconds !== "number" || + !Number.isFinite(value.totalDurationSeconds) || + value.totalDurationSeconds < 0 || + !Array.isArray(value.exercisesCompleted) + ) + throw new Error("The backup history is invalid."); + const exercisesCompleted = value.exercisesCompleted.map((ex: unknown) => { + if ( + !record(ex) || + typeof ex.exerciseId !== "string" || + !date(ex.completedAt) || + typeof ex.actualDurationSeconds !== "number" || + !Number.isFinite(ex.actualDurationSeconds) || + ex.actualDurationSeconds < 0 || + typeof ex.skipped !== "boolean" + ) + throw new Error("An exercise record is invalid."); + return { + exerciseId: ex.exerciseId, + completedAt: new Date(String(ex.completedAt)), + actualDurationSeconds: ex.actualDurationSeconds, + skipped: ex.skipped, + ...(Number.isInteger(ex.sequenceIndex) && + Number(ex.sequenceIndex) >= 0 + ? { sequenceIndex: Number(ex.sequenceIndex) } + : {}), + }; + }); + return { + id: value.id, + workoutPlanId: value.workoutPlanId, + startedAt: new Date(String(value.startedAt)), + completedAt: new Date(String(value.completedAt)), + totalDurationSeconds: value.totalDurationSeconds, + status: value.status as WorkoutSession["status"], + exercisesCompleted, + ...(Number.isInteger(value.perceivedDifficulty) && + Number(value.perceivedDifficulty) >= 1 && + Number(value.perceivedDifficulty) <= 5 + ? { + perceivedDifficulty: value.perceivedDifficulty as + | 1 + | 2 + | 3 + | 4 + | 5, + } + : {}), + ...(typeof value.notes === "string" && value.notes.length <= 10000 + ? { notes: value.notes } + : {}), + }; + }, + ); + if (new Set(sessions.map((session) => session.id)).size !== sessions.length) + throw new Error("The backup contains duplicate session IDs."); + return { + format: "prego-backup", + version: 1, + exportedAt: String(data.exportedAt), + profile, + isOnboarded: data.isOnboarded, + completedSessions: sessions, + }; +} +export async function exportBackup() { + const data = JSON.stringify(createBackup(), null, 2); + if (Capacitor.isNativePlatform()) { + const result = await Filesystem.writeFile({ + path: "prego-backup.json", + directory: Directory.Cache, + data, + encoding: Encoding.UTF8, + }); + try { + await Share.share({ + title: "Prego backup", + url: result.uri, + dialogTitle: "Save your private backup", + }); + } finally { + await Filesystem.deleteFile({ + path: "prego-backup.json", + directory: Directory.Cache, + }); + } + } else { + const url = URL.createObjectURL( + new Blob([data], { type: "application/json" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = "prego-backup.json"; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } +} +export async function restoreBackup(backup: Backup) { + // Revalidate at the API boundary; callers cannot bypass file validation. + const validated = parseBackup(JSON.stringify(backup)); + await flushStorage(); + const user = { profile: validated.profile, isOnboarded: validated.isOnboarded }; + const sessions = { currentSession: null, currentExerciseIndex: 0, isPaused: false, completedSessions: validated.completedSessions }; + await replaceDataRecords({ + "prego-user-storage": JSON.stringify({ state: user, version: 1 }), + "prego-workout-session-storage": JSON.stringify({ state: sessions, version: 1 }), + "prego-active-timer": null, + }); + updateMemory(user, sessions); +} +function updateMemory( + user: Pick, 'profile' | 'isOnboarded'>, + sessions: Pick, 'currentSession' | 'currentExerciseIndex' | 'isPaused' | 'completedSessions'>, +) { + // The transaction already wrote and verified these values. Avoid duplicate + // background writes (especially recreating records after full deletion). + const userStorage = useUserStore.persist.getOptions().storage; + const sessionStorage = useWorkoutSessionStore.persist.getOptions().storage; + const memoryOnly = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; + useUserStore.persist.setOptions({ storage: memoryOnly }); + useWorkoutSessionStore.persist.setOptions({ storage: memoryOnly }); + try { + useUserStore.setState(user); + useWorkoutSessionStore.setState(sessions); + } finally { + useUserStore.persist.setOptions({ storage: userStorage }); + useWorkoutSessionStore.persist.setOptions({ storage: sessionStorage }); + } +} +export async function deleteLocalData() { + await flushStorage(); + await replaceDataRecords({ "prego-user-storage": null, "prego-workout-session-storage": null, "prego-active-timer": null }); + updateMemory({ profile: null, isOnboarded: false }, { currentSession: null, currentExerciseIndex: 0, isPaused: false, completedSessions: [] }); + if (Capacitor.isNativePlatform()) { + try { + await Filesystem.deleteFile({ + path: "prego-backup.json", + directory: Directory.Cache, + }); + } catch { + /* An export may not exist. */ + } + } + storageHealth.report(null); +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..5d34562 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,214 @@ +import { Capacitor } from "@capacitor/core"; +import { + SecureStorage, + KeychainAccess, +} from "@aparajita/capacitor-secure-storage"; +import { Preferences } from "@capacitor/preferences"; +import type { StateStorage } from "zustand/middleware"; + +const listeners = new Set<() => void>(); +let message: string | null = null; +export const storageHealth = { + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot: () => message, + report: (value: string | null) => { + message = value; + listeners.forEach((listener) => listener()); + }, +}; +const read = async (key: string) => { + if (!Capacitor.isNativePlatform()) return localStorage.getItem(key); + const secure = await SecureStorage.get(key, false, false); + if (secure !== null && typeof secure !== "string") + throw new Error("Invalid secure record"); + // Both prior native preferences and the old webview store are migrated only after validation. + return ( + secure ?? + (await Preferences.get({ key })).value ?? + localStorage.getItem(key) + ); +}; +export function validateStoredRecord(key: string, text: string) { + const parsed = JSON.parse(text); + if (key === "prego-user-storage") { + const state = parsed?.state; + if (!state || typeof state.isOnboarded !== "boolean") + throw new Error("Invalid profile storage"); + if (state.profile !== null) { + const profile = state.profile; + if ( + !profile || + typeof profile.id !== "string" || + typeof profile.displayName !== "string" || + !Number.isFinite(Date.parse(profile.dueDate)) || + !profile.preferences || + !Array.isArray(profile.healthConditions) || + typeof profile.preferences.soundEnabled !== "boolean" || + !Array.isArray(profile.preferences.reminderDays) + ) + throw new Error("Invalid profile storage"); + } + } + if (key === "prego-workout-session-storage") { + const state = parsed?.state; + if ( + !state || + !Array.isArray(state.completedSessions) || + !Number.isInteger(state.currentExerciseIndex) || + typeof state.isPaused !== "boolean" + ) + throw new Error("Invalid session storage"); + for (const session of [ + ...state.completedSessions, + ...(state.currentSession ? [state.currentSession] : []), + ]) { + if ( + !session || + typeof session.id !== "string" || + typeof session.workoutPlanId !== "string" || + !Array.isArray(session.exercisesCompleted) || + !Number.isFinite(Date.parse(session.startedAt)) || + !Number.isFinite(session.totalDurationSeconds) || + session.totalDurationSeconds < 0 + ) + throw new Error("Invalid session storage"); + for (const exercise of session.exercisesCompleted) + if ( + !exercise || + !Number.isFinite(Date.parse(exercise.completedAt)) || + typeof exercise.exerciseId !== "string" || + !Number.isFinite(exercise.actualDurationSeconds) + ) + throw new Error("Invalid exercise storage"); + } + } +} +const put = async (key: string, value: string) => { + if (Capacitor.isNativePlatform()) + await SecureStorage.set( + key, + value, + false, + false, + KeychainAccess.whenUnlockedThisDeviceOnly, + ); + else localStorage.setItem(key, value); +}; +const remove = async (key: string) => { + if (Capacitor.isNativePlatform()) { + await SecureStorage.remove(key, false); + await Preferences.remove({ key }); + } else localStorage.removeItem(key); +}; +const failedWrites = new Set(); +const queues = new Map>(); +function serial(key: string, operation: () => Promise) { + const task = (queues.get(key) ?? Promise.resolve()) + .catch(() => {}) + .then(operation); + queues.set(key, task); + return task; +} +export const durableStorage: StateStorage = { + async getItem(key) { + await queues.get(key)?.catch(() => {}); + try { + const primary = await read(key); + if (primary !== null) { + validateStoredRecord(key, primary); + if (Capacitor.isNativePlatform()) { + if ((await SecureStorage.get(key, false, false)) === null) { + await put(key, primary); + await put(`${key}::shadow`, primary); + if ((await SecureStorage.get(key, false, false)) !== primary) + throw new Error("Migration verification failed"); + } + await Preferences.remove({ key }); + await Preferences.remove({ key: `${key}::shadow` }); + localStorage.removeItem(key); + localStorage.removeItem(`${key}::shadow`); + } + return primary; + } + const backup = await read(`${key}::shadow`); + if (backup !== null) { + validateStoredRecord(key, backup); + await put(key, backup); + storageHealth.report( + "Recovered a local data copy. Export a backup in You.", + ); + } + return backup; + } catch { + try { + const backup = await read(`${key}::shadow`); + if (backup !== null) { + validateStoredRecord(key, backup); + await put(key, backup); + storageHealth.report( + "Recovered a local data copy. Export a backup in You.", + ); + return backup; + } + } catch { + /* Report below; preserve corrupt values for explicit recovery. */ + } + storageHealth.report( + "Unable to read saved data. Your stored copy has been preserved. Try restoring an exported backup.", + ); + throw new Error("Saved data unavailable"); + } + }, + setItem(key, value) { + return serial(key, async () => { + try { + await put(key, value); + await put(`${key}::shadow`, value); + failedWrites.delete(key); + } catch { + failedWrites.add(key); + storageHealth.report( + "Changes could not be saved. Keep the app open and export a backup in You.", + ); + } + }); + }, + removeItem(key) { + return serial(key, async () => { + await remove(key); + await remove(`${key}::shadow`); + if (Capacitor.isNativePlatform()) { + localStorage.removeItem(key); + localStorage.removeItem(`${key}::shadow`); + } + failedWrites.delete(key); + }); + }, +}; +export async function flushStorage() { + await Promise.all([...queues.values()]); + if (failedWrites.size) + throw new Error( + "Some changes could not be saved. Export your data and try again.", + ); +} + +// Used by the journalled data-control transaction. Unlike normal background +// saves, these writes reject and verify before reporting success. +export const transactionStorage = { + get: read, + async set(key: string, value: string) { + await put(key, value); + if ((await read(key)) !== value) throw new Error("Saved copy verification failed"); + }, + async remove(key: string) { + await remove(key); + localStorage.removeItem(key); + if ((await read(key)) !== null) throw new Error("Deletion verification failed"); + }, +}; diff --git a/src/lib/storageTransaction.ts b/src/lib/storageTransaction.ts new file mode 100644 index 0000000..8447371 --- /dev/null +++ b/src/lib/storageTransaction.ts @@ -0,0 +1,85 @@ +import { transactionStorage, validateStoredRecord, storageHealth } from './storage'; + +export const transactionKey = 'prego-data-transaction'; +export const dataKeys = ['prego-user-storage', 'prego-workout-session-storage', 'prego-active-timer'] as const; +const recordKeys = dataKeys.flatMap(key => [key, `${key}::shadow`]); +export interface TransactionStorage { + get(key: string): Promise; + set(key: string, value: string): Promise; + remove(key: string): Promise; +} +interface Journal { + version: 1; + phase: 'prepared' | 'committed'; + before: Record; + after: Record; +} +function parseJournal(text: string): Journal { + const value = JSON.parse(text) as Journal; + if (value.version !== 1 || !['prepared', 'committed'].includes(value.phase)) + throw new Error('Unsupported recovery record'); + for (const records of [value.before, value.after]) { + if (!records || Object.keys(records).length !== recordKeys.length || + recordKeys.some(key => !(key in records) || (records[key] !== null && typeof records[key] !== 'string'))) + throw new Error('Invalid recovery record'); + } + for (const key of recordKeys) { + const next = value.after[key]; + if (next !== null) validateStoredRecord(key.replace(/::shadow$/, ''), next); + } + return value; +} +async function apply(records: Journal['before'], storage: TransactionStorage) { + for (const key of recordKeys) { + if (records[key] === null) await storage.remove(key); + else await storage.set(key, records[key]); + } +} +export async function recoverDataTransaction(storage: TransactionStorage = transactionStorage) { + const text = await storage.get(transactionKey); + if (text === null) return false; + const journal = parseJournal(text); + // Prepared operations roll back; a verified committed operation rolls forward. + // Retain the journal if any recovery write fails so the next launch retries. + await apply(journal.phase === 'committed' ? journal.after : journal.before, storage); + await storage.remove(transactionKey); + storageHealth.report('Recovered an interrupted data change. Your saved data is ready.'); + return true; +} +let active = false; +export async function replaceDataRecords( + values: Record, + storage: TransactionStorage = transactionStorage, +) { + if (active) throw new Error('Another data change is in progress.'); + active = true; + try { + await recoverDataTransaction(storage); + const before: Journal['before'] = {}; + const after: Journal['after'] = {}; + for (const key of dataKeys) { + if (values[key] !== null) validateStoredRecord(key, values[key]); + for (const recordKey of [key, `${key}::shadow`]) { + before[recordKey] = await storage.get(recordKey); + after[recordKey] = values[key]; + } + } + const journal: Journal = { version: 1, phase: 'prepared', before, after }; + await storage.set(transactionKey, JSON.stringify(journal)); + try { + await apply(after, storage); + await storage.set(transactionKey, JSON.stringify({ ...journal, phase: 'committed' })); + } catch { + try { + await apply(before, storage); + await storage.remove(transactionKey); + } catch { + storageHealth.report('A data change was interrupted. Keep your backup. Restart Prego to recover the saved copy.'); + throw new Error('Recovery is pending. Restart Prego before making further changes.'); + } + throw new Error('The data change failed. Your previous records were restored.'); + } + try { await storage.remove(transactionKey); } + catch { storageHealth.report('Data saved. Recovery cleanup will finish on your next launch.'); } + } finally { active = false; } +} diff --git a/src/main.tsx b/src/main.tsx index 51ec090..74ce17b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,25 +2,28 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './app/App' +import { Capacitor } from '@capacitor/core'; +import { useUserStore, useWorkoutSessionStore } from './stores'; +import { recoverDataTransaction } from './lib/storageTransaction'; -// Analytics Placeholder -// Drop in your VITE_POSTHOG_KEY in your .env file to enable analytics -const POSTHOG_KEY = import.meta.env.VITE_POSTHOG_KEY; -if (POSTHOG_KEY) { - // 1. npm install posthog-js - // 2. import posthog from 'posthog-js' - // 3. posthog.init(POSTHOG_KEY, { api_host: 'https://app.posthog.com' }) - console.log('Analytics initialized (placeholder)'); -} +let recoveryFailed = false; +try { + await recoverDataTransaction(); + await Promise.all([useUserStore.persist.rehydrate(), useWorkoutSessionStore.persist.rehydrate()]); +} catch { recoveryFailed = true; } createRoot(document.getElementById('root')!).render( - + {recoveryFailed ?
+

Your saved data needs recovery

+

Prego preserved the saved copy and paused loading. Unlock your device and try again. Keep any exported backup.

+ +
: }
, ) // Register service worker for PWA offline support -if ('serviceWorker' in navigator) { +if (import.meta.env.PROD && !Capacitor.isNativePlatform() && 'serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => { // Service worker registration failed — app works fine without it diff --git a/src/pages/ActiveWorkoutPage.tsx b/src/pages/ActiveWorkoutPage.tsx index 46e72ff..abb8325 100644 --- a/src/pages/ActiveWorkoutPage.tsx +++ b/src/pages/ActiveWorkoutPage.tsx @@ -1,3 +1,5 @@ +import { workoutAvailability } from '../lib/contentReview'; +import { usePregnancyStage } from '../hooks/usePregnancyStage'; import { useParams, useNavigate } from 'react-router-dom'; import { useState, useMemo, useEffect, useCallback } from 'react'; import { @@ -30,7 +32,9 @@ function ActiveWorkoutPage() { const workout = workoutId ? getWorkoutById(workoutId) : null; const preferences = useUserStore((state) => state.profile?.preferences); - const userTrimester = useUserStore((state) => state.profile?.currentTrimester); + const profile = useUserStore(state => state.profile); + const userTrimester = usePregnancyStage().currentTrimester; + const availability = workout ? workoutAvailability(workout, profile) : { available: false, reason: 'Workout not found.' }; const [soundEnabled, setSoundEnabled] = useState(preferences?.soundEnabled ?? true); const [showExitModal, setShowExitModal] = useState(false); @@ -84,6 +88,7 @@ function ActiveWorkoutPage() { } return { + persistenceKey: currentSession?.workoutPlanId === workout.id ? currentSession.id : undefined, countdownSeconds: 5, exercises: workout.exercises.map((we) => { const exercise = getExerciseById(we.exerciseId); @@ -94,9 +99,9 @@ function ActiveWorkoutPage() { restSeconds: we.restSeconds }; }), - onExerciseComplete: (index) => { + onExerciseComplete: (index, skipped, actualSeconds) => { const ex = workout.exercises[index]; - completeExercise(ex.exerciseId, ex.durationSeconds || 30); + completeExercise(ex.exerciseId, actualSeconds ?? ex.durationSeconds ?? 30, skipped, index); playSound('complete'); }, onWorkoutComplete: () => { @@ -109,13 +114,13 @@ function ActiveWorkoutPage() { } } }; - }, [workout, completeExercise, playSound]); + }, [workout, currentSession?.id, currentSession?.workoutPlanId, completeExercise, playSound]); const { state, start, pause, resume, skip, goToExercise } = useTimer(timerConfig); // Start workout session when component mounts — skip if resuming an existing session useEffect(() => { - if (workout && currentSession?.workoutPlanId !== workout.id) { + if (availability.available && workout && currentSession?.workoutPlanId !== workout.id) { startWorkout(workout.id); } // Only run on mount — don't restart when currentSession reference changes @@ -152,6 +157,8 @@ function ActiveWorkoutPage() { ); } + if (!availability.available) return

This session is not available yet

{availability.reason}

; + const currentExercise = state.phase === 'complete' ? null @@ -176,6 +183,7 @@ function ActiveWorkoutPage() { variant="ghost" size="icon" onClick={handleExit} + aria-label="End workout" className="text-white hover:bg-white/10" > @@ -313,6 +321,7 @@ function ActiveWorkoutPage() { -
- - ); - } - - const isSafeForCurrentTrimester = profile - ? exercise.safeTrimesters.includes(profile.currentTrimester) - : true; - - const currentTrimesterModification = profile - ? exercise.modifications.find((m) => m.trimester === profile.currentTrimester) - : null; - - // Check if any of the user's health conditions match exercise contraindications - const matchingContraindications = profile?.healthConditions.length - ? exercise.contraindications.filter((contra) => - profile.healthConditions.some((condition) => - contra.toLowerCase().includes(condition.toLowerCase()) || - condition.toLowerCase().includes(contra.toLowerCase()) - ) - ) - : []; - +import { useParams, Link } from "react-router-dom"; +import { getExerciseById } from "../data/exercises"; +import { PageLayout } from "../shared/components/layout/PageLayout"; +export function ExerciseDetailPage() { + const { exerciseId } = useParams(); + const exercise = getExerciseById(exerciseId ?? ""); return ( - - {/* Back button */} -
- -
- - {/* Hero section */} -
-
- -
-

{exercise.name}

-
- - - {exercise.category.replace('-', ' ')} - -
-
-
- -

{exercise.description}

-
- - {/* Safety warning if not safe for current trimester */} - {!isSafeForCurrentTrimester && profile && ( -
- - - -
-

- Not recommended for your trimester -

-

- This exercise may not be safe during trimester {profile.currentTrimester}. - Please consult your healthcare provider. -

-
-
-
-
- )} - - {/* Contraindication warning for user's health conditions */} - {matchingContraindications.length > 0 && ( -
- - - -
-

- May conflict with your health conditions -

-

- Based on your profile, this exercise may not be suitable. - Please consult your healthcare provider before attempting. -

-
-
-
-
- )} - - {/* Modification notice */} - {currentTrimesterModification && ( -
- - - -
-

- Modification for Trimester {profile?.currentTrimester} -

-

- {currentTrimesterModification.description} -

-
-
-
-
- )} - -
- {/* Quick Info */} -
- - - -

- {Math.round(exercise.defaultDurationSeconds / 60)} -

-

minutes

-
-
- - -

Safe for

-
- {exercise.safeTrimesters.map((t) => ( - - ))} -
-
-
-
- - {/* Instructions */} - - -

How to do it

-
    - {exercise.instructions.map((instruction, index) => ( -
  1. - - {index + 1} - - {instruction} -
  2. - ))} -
-
-
- - {/* Target Muscles */} - - -

Target Areas

-
- {exercise.targetMuscles.map((muscle) => ( - - {muscle.replace('-', ' ')} - - ))} -
-
-
- - {/* Equipment */} - {exercise.equipmentRequired.length > 0 && ( - - -

Equipment Needed

-
- {exercise.equipmentRequired.map((equipment) => ( - - {equipment} - - ))} -
-
-
- )} - - {/* Contraindications */} - {exercise.contraindications.length > 0 && ( - - -

- - Avoid if you have -

-
    - {exercise.contraindications.map((item, index) => ( -
  • - - {item} -
  • - ))} -
-
-
- )} -
+ +

+ Instructions, modifications and demonstrations will be available after + qualified review. An illustration or a listed trimester does not + establish exercise suitability. +

+ + Safety & care +
); } - -export { ExerciseDetailPage }; diff --git a/src/pages/ExercisesPage.tsx b/src/pages/ExercisesPage.tsx index abbb667..ebca1f4 100644 --- a/src/pages/ExercisesPage.tsx +++ b/src/pages/ExercisesPage.tsx @@ -1,3 +1,4 @@ +import { usePregnancyStage } from '../hooks/usePregnancyStage'; import { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { Search, Filter, ChevronRight, Clock, X } from 'lucide-react'; @@ -8,7 +9,6 @@ import { IntensityBadge, Badge } from '../shared/components/ui/Badge'; import { Modal } from '../shared/components/ui/Modal'; import { ExercisePlaceholder } from '../shared/components/ui/ExercisePlaceholder'; import { filterExercises } from '../data/exercises'; -import { useUserStore } from '../stores'; import type { ExerciseCategory, IntensityLevel, Trimester } from '../types'; const categories: { value: ExerciseCategory; label: string }[] = [ @@ -27,12 +27,12 @@ const intensities: { value: IntensityLevel; label: string }[] = [ ]; function ExercisesPage() { - const profile = useUserStore((state) => state.profile); + const stage = usePregnancyStage(); const [searchQuery, setSearchQuery] = useState(''); const [showFilters, setShowFilters] = useState(false); const [selectedTrimester, setSelectedTrimester] = useState( - profile?.currentTrimester + stage.currentTrimester ); const [selectedCategory, setSelectedCategory] = useState(); const [selectedIntensity, setSelectedIntensity] = useState(); @@ -57,7 +57,7 @@ function ExercisesPage() { }; return ( - + {/* Search and Filter Bar */}
@@ -139,7 +139,7 @@ function ExercisesPage() { {exercise.name}

- {exercise.description} + Instructions available after professional review.

@@ -176,7 +176,7 @@ function ExercisesPage() { {/* Trimester Filter */}
{[1, 2, 3].map((t) => ( diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index e1b25c3..fadd312 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,220 +1,212 @@ -import { Link, useNavigate } from 'react-router-dom'; -import { Play, Dumbbell, Clock, Flame, ChevronRight, Sparkles, RotateCcw, X } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { PageLayout } from '../shared/components/layout/PageLayout'; -import { Card, CardContent } from '../shared/components/ui/Card'; -import { Button } from '../shared/components/ui/Button'; -import { TrimesterBadge, IntensityBadge } from '../shared/components/ui/Badge'; -import { useUserStore, useWorkoutSessionStore, selectCompletedWorkoutsCount, selectTotalMinutesExercised } from '../stores'; -import { workoutPlans, getWorkoutById } from '../data/workouts'; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { + ArrowUpRight, + Clock, + Leaf, + ChevronRight, + Check, + Sparkles, +} from "lucide-react"; +import { PageLayout } from "../shared/components/layout/PageLayout"; +import { + useUserStore, + useWorkoutSessionStore, + selectCompletedWorkoutsCount, + selectTotalMinutesExercised, +} from "../stores"; +import { usePregnancyStage } from "../hooks/usePregnancyStage"; +import { workoutPlans } from "../data/workouts"; +import { StudioMotion } from "../shared/components/ui/StudioMotion"; function HomePage() { - const navigate = useNavigate(); const profile = useUserStore((state) => state.profile); - const isOnboarded = useUserStore((state) => state.isOnboarded); - const completedWorkouts = useWorkoutSessionStore(selectCompletedWorkoutsCount); - const totalMinutes = useWorkoutSessionStore(selectTotalMinutesExercised); - const currentSession = useWorkoutSessionStore((state) => state.currentSession); - const clearCurrentSession = useWorkoutSessionStore((state) => state.clearCurrentSession); - const [dismissedResume, setDismissedResume] = useState(false); - - // Redirect to onboarding if not completed - useEffect(() => { - if (!isOnboarded) { - navigate('/onboarding'); - } - }, [isOnboarded, navigate]); - - if (!profile) { - return ( - -
-
-

Loading your profile...

-
- - ); - } - - const interruptedWorkout = currentSession && !dismissedResume - ? getWorkoutById(currentSession.workoutPlanId) - : null; - - // Get recommended workouts for current trimester - const recommendedWorkouts = workoutPlans - .filter(w => { - const isAllTrimester = w.weekRange.start === 1 && w.weekRange.end === 40; - return isAllTrimester || w.trimester === profile.currentTrimester; - }) - .slice(0, 3); - - const greeting = getGreeting(); - + const count = useWorkoutSessionStore(selectCompletedWorkoutsCount); + const minutes = useWorkoutSessionStore(selectTotalMinutesExercised); + const stage = usePregnancyStage(); + const [duration, setDuration] = useState(10); + const session = + workoutPlans.find( + (workout) => + workout.totalDurationMinutes <= duration && + (workout.trimester === stage.currentTrimester || + workout.weekRange.end === 40), + ) ?? workoutPlans[0]; return ( - {/* Welcome Section */} -
-

- {greeting}, {profile.displayName.split(' ')[0]}! -

-

- Week {profile.currentWeek} of your pregnancy journey -

+
+ YOUR DAILY MOMENT + + {profile && stage.status === "pregnancy" + ? `Week ${stage.currentWeek} · Trimester ${stage.currentTrimester}` + : "At your own pace"} +
- - {/* Resume interrupted workout */} - {interruptedWorkout && ( - - -
- -
-
-

Resume workout?

-

- {interruptedWorkout.name} — {currentSession!.exercisesCompleted.length} exercise{currentSession!.exercisesCompleted.length !== 1 ? 's' : ''} done +

+
+

+ {profile + ? `Hello, ${profile.displayName.split(" ")[0]}.` + : "Movement for every stage."} +

+

+ A little movement. +
+ + A moment for you. + +

+

+ Make room for movement that fits your day. Clear guidance, small + steps, and no pressure to keep up. +

+ {!profile && ( + + Make it yours{" "} +