8 Commits
Author SHA1 Message Date
westfarn 9e1449f0db Add public account-deletion page for Google Play (#103) (#110)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m23s
## Summary
- Closes [#103](#103).
- Adds public SPA route `/account-deletion/` (no auth) with Hesychia / `ai.hesychia.chat` branding, in-app deletion steps, soft-delete data retention copy, and a contact path (30-day SLA) for users who cannot sign in.
- Documents Play Console **Account deletion URL** in `ANDROID.md` and `android/README.md` (`https://hesychia.ai/account-deletion/`, legacy host, HashRouter form).

## Notes
- Retention copy matches backend soft-delete (v1): no claim of full erasure / hard purge yet.
- Play Console field still needs to be set out-of-band after deploy.

## Test plan
- [ ] Open `/account-deletion/` signed out — page loads (no redirect to sign-in)
- [ ] Confirm steps match Account → Delete my account + email confirm
- [ ] Confirm retention sections are accurate vs soft-delete
- [ ] `npm run test:ci -- --testPathPattern=AccountDeletionPage`
- [ ] After beta deploy, set Play Console Account deletion URL to the public HTTPS URLReviewed-on: #110
2026-08-04 10:53:48 -07:00
westfarn 0aad5394e9 Default to THINKING; hide Sources card (#108) (#109)
Deploy Beta / deploy-beta (push) Successful in 2m22s
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 12s
## Summary
- Closes [#108](#108) — hide Sources card; clicking inline `[n]` opens that source in a new tab.
- Follow-up to [#106](#106) / [#107](#107): default fixed `modelName` is now `THINKING` (not `FAST`).

## Test plan
- [ ] Sent messages use THINKING.
- [ ] Grounded answers show inline `[n]` markers but no Sources card.
- [ ] Clicking `[n]` opens the matching URL in a new tab.Reviewed-on: #109
2026-08-04 10:41:15 -07:00
westfarn 6ec239d2e0 Remove FAST/THINKING model dropdown from chat composer (#106) (#107)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m20s
## Summary
- Closes [#106](#106).
- Removes the FAST / THINKING `<select>` from the chat input bar.
- Continues to send a fixed `modelName` of `FAST` on WebSocket sends so the backend contract is unchanged.

## Test plan
- [ ] Open chat dashboard — composer shows attach + text field + send only (no FAST/THINKING dropdown).
- [ ] Send a message — stream still works.
- [ ] Send with attachment — still works.Reviewed-on: #107
2026-08-04 10:17:32 -07:00
westfarn 27b4fd7698 Settings toggle: use previous conversations as context / RAG (#66) (#105)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m22s
## Summary
- Closes [#66](#66) — Account Preferences toggle for previous-conversation context / RAG.
- Reads/writes `use_conversation_context` via `GET/POST /conversation_preferences` (backend [#33](ai_ml_operations/chat_backend#33)).
- Default off; MUI tooltip explains use of previous conversations; loading + error states with revert on failed update.

## Depends on
Backend PR for [chat_backend#33](ai_ml_operations/chat_backend#33) (merge/deploy backend first or together).

## Test plan
- [ ] Open `/account/` — toggle visible under Account Preferences, off by default
- [ ] Hover info icon — tooltip about previous conversations customizing experience
- [ ] Toggle on — persists after reload; toggle off — persists
- [ ] Simulate API failure — value reverts and error alert shown
- [ ] Disable while loading/savingReviewed-on: #105
2026-08-04 06:28:12 -07:00
westfarn fb3e138ea2 Fix MessageContext exhaustive-deps for CI builds
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m24s
Stabilize schedulePostStreamRefetch with useCallback so CI=true ESLint no longer fails beta deploy.
2026-08-04 07:05:55 -05:00
westfarn a7c5d1a2aa Drop docx export to fix CRA beta build
Unit Tests / test (push) Successful in 11s
Deploy Beta / unit-tests (push) Successful in 12s
Deploy Beta / deploy-beta (push) Failing after 2m28s
docx@9 ESM trips Babel during react-scripts build; keep PDF/CSV/XLSX/TXT.
2026-08-04 06:22:54 -05:00
westfarn 3313738990 Live activity indicator replacing typing dots (#96) (#104)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Failing after 2m16s
## Summary
- Closes [#96](#96).
- Parses versioned WS `status` frames in `MessageContext` (`activityStage` / `label` / `detail` / `history`).
- New `ActivityIndicator`: completed stages with checks, ~400ms min display, elapsed/still-working notes, a11y live region, reduced-motion, dots fallback when no frames.
- Wired through `ConversationDetailCard` + `AsyncDashboard2`; analytics `ACTIVITY_STAGE_COMPLETED`.

## Depends on
Backend status emission from [chat_backend PR](https://git.aimloperations.com/ai_ml_operations/chat_backend/pulls) (`feat/62-p4-evals-63-agentic`). Without it, UI keeps three-dot fallback.

## Test plan
- [x] Jest: `wsFrames`, `ActivityIndicator`, `ConversationDetailCard`, `MessageContext`
- [ ] Manual light/dark + 320px width
- [ ] Manual with backend status frames: Evaluating → Searching → Refining
- [ ] Manual: stream interrupt clears indicator
- [ ] Capacitor Android smokeReviewed-on: #104
2026-08-04 04:08:57 -07:00
westfarn ce86f56d37 RevenueCat Capacitor IAP; keep Stripe on web (#100) (#102)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Failing after 2m20s
## Summary
- Native Capacitor: RevenueCat purchase/restore (`@revenuecat/purchases-capacitor`)
- Web: Stripe checkout/portal unchanged
- Billing history shows store rows (`provider` / `revenuecat_store`)
- RC `logIn`/`logOut` on auth lifecycle; appUserID = user pk

Closes #100. Depends on backend [chat_backend#68](ai_ml_operations/chat_backend#68) / monetization PR.

## Test plan
- [x] Unit tests: BillingSection, revenueCat, finance helpers, SignIn/Up, AuthCallback, Header2, DeleteAccount (38 OK)
- [ ] Fill `REACT_APP_REVENUECAT_*` keys in `.env.mobile`
- [ ] Align RC offerings/products with plan slugs
- [ ] `npm run build:mobile` + `cap sync`; sandbox purchase on Android/iOS
- [ ] Confirm web Stripe checkout/portal still work
- [ ] After RC webhook, Account → Billing history shows store invoiceReviewed-on: #102
2026-08-04 03:43:10 -07:00
48 changed files with 2022 additions and 649 deletions
+6
View File
@@ -4,3 +4,9 @@
# REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/ # REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/
REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/ REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/
REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/ REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/
# RevenueCat public SDK keys (native IAP). Leave empty until store apps are wired.
REACT_APP_REVENUECAT_APPLE_API_KEY=
REACT_APP_REVENUECAT_GOOGLE_API_KEY=
# Optional: pin a specific offering identifier (defaults to current offering).
# REACT_APP_REVENUECAT_OFFERING_ID=
+1
View File
@@ -31,6 +31,7 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
| Message Rated | `MESSAGE_RATED` | MessageActions | `{ rating: 'up' \| 'down' \| 'cleared' }` | | Message Rated | `MESSAGE_RATED` | MessageActions | `{ rating: 'up' \| 'down' \| 'cleared' }` |
| Message Rating Reason | `MESSAGE_RATING_REASON` | MessageActions | `{ reason, hasComment }` | | Message Rating Reason | `MESSAGE_RATING_REASON` | MessageActions | `{ reason, hasComment }` |
| Message Exported | `MESSAGE_EXPORTED` | MessageActions, AsyncDashboard2 | `{ format, scope }` | | Message Exported | `MESSAGE_EXPORTED` | MessageActions, AsyncDashboard2 | `{ format, scope }` |
| Activity Stage Completed | `ACTIVITY_STAGE_COMPLETED` | MessageContext | `{ stage, durationMs }` (no label/detail text) |
## Identify ## Identify
+25 -3
View File
@@ -40,6 +40,20 @@ npm run android:sync
Default `.env.mobile` matches production (`chatbackend.aimloperations.com`). Point it at beta to flip the shell without touching web deploys. Optional gitignored override: `.env.mobile.local`. Default `.env.mobile` matches production (`chatbackend.aimloperations.com`). Point it at beta to flip the shell without touching web deploys. Optional gitignored override: `.env.mobile.local`.
### RevenueCat IAP (#100)
Native billing uses `@revenuecat/purchases-capacitor` (Capacitor 7 → package **11.x**). Web keeps Stripe Checkout / Customer Portal.
Set public SDK keys in `.env.mobile` (empty placeholders OK until store apps ship):
| Variable | Purpose |
|----------|---------|
| `REACT_APP_REVENUECAT_APPLE_API_KEY` | iOS public SDK key |
| `REACT_APP_REVENUECAT_GOOGLE_API_KEY` | Android public SDK key |
| `REACT_APP_REVENUECAT_OFFERING_ID` | Optional offering pin (else current) |
After install / key changes: `npm run build:mobile` (runs `cap sync`). More detail: [`MONETIZATION.md`](MONETIZATION.md).
## Versioning ## Versioning
In `android/app/build.gradle`: In `android/app/build.gradle`:
@@ -118,12 +132,20 @@ Out of band ops (not automated here):
1. Google Play developer account 1. Google Play developer account
2. Privacy policy URL (product site / legal) 2. Privacy policy URL (product site / legal)
3. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView) 3. **Account deletion URL** (Data safety / Account deletion) → public page:
4. Screenshots for phone (+ tablet if targeting) - Canonical: `https://hesychia.ai/account-deletion/`
5. Upload `.aab` to **internal testing** track before production - Legacy host until DNS cutover: `https://chat.aimloperations.com/account-deletion/`
- Capacitor HashRouter: `/#/account-deletion/`
4. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView)
5. Screenshots for phone (+ tablet if targeting)
6. Upload `.aab` to **internal testing** track before production
In-app delete remains Account → **Delete my account** (`DELETE /api/user/`). The public URL
above is for Play reviewers and users who never open the app (#103).
## Related issues ## Related issues
- [#20](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/20) — this Android wrap - [#20](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/20) — this Android wrap
- [#21](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/21) — iOS (reuses this scaffolding) - [#21](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/21) — iOS (reuses this scaffolding)
- [#22](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/22) / [#23](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/23) / [#24](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/24) — auth / WS / routing - [#22](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/22) / [#23](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/23) / [#24](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/24) — auth / WS / routing
- [#103](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/103) — Play public account-deletion URL
+40
View File
@@ -0,0 +1,40 @@
# Monetization (web Stripe + native RevenueCat)
## Channels
| Runtime | Checkout / manage | Ledger |
|---------|-------------------|--------|
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/monetization/...` | Stripe webhooks |
| Native Capacitor | RevenueCat IAP (`@revenuecat/purchases-capacitor`) | RevenueCat webhooks → same invoice/payment tables |
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`.
## App user id
RevenueCat `appUserID` = Django user pk as string.
1. Prefer `id` from `/user/get/` (serializer returns all model fields).
2. Else JWT `user_id` claim (`SIMPLE_JWT.USER_ID_CLAIM`).
Backend webhook resolver accepts numeric pk (or email fallback).
## Mobile env keys
In `.env.mobile`:
- `REACT_APP_REVENUECAT_APPLE_API_KEY`
- `REACT_APP_REVENUECAT_GOOGLE_API_KEY`
- `REACT_APP_REVENUECAT_OFFERING_ID` (optional)
Package identifiers in the RC dashboard should include plan slugs (e.g. `founders`) so `purchasePlan(planSlug)` can match packages/products. Optional backend `REVENUECAT_PRODUCT_PLAN_MAP` maps product id → plan slug.
## Auth hooks
- `Purchases.configure` once on native (first purchase / logIn).
- `logIn(appUserID)` after SignIn / SignUp / AuthCallback / AccountContext load.
- `logOut` on Header2 sign-out and Delete account.
## Related
- Issue #100 (FE) + companion backend RevenueCat webhook PR
- [`ANDROID.md`](ANDROID.md) / [`IOS.md`](IOS.md) for store builds
+9 -3
View File
@@ -107,9 +107,15 @@ Without `keystore.properties`, release builds are unsigned; debug builds do not
1. [Play Console](https://play.google.com/console) → app **Hesychia** (`ai.hesychia.chat`). 1. [Play Console](https://play.google.com/console) → app **Hesychia** (`ai.hesychia.chat`).
2. Complete store listing: privacy policy URL, data-safety form, screenshots. 2. Complete store listing: privacy policy URL, data-safety form, screenshots.
3. **Testing → Internal testing** → create release → upload `app-release.aab`. 3. Set **Account deletion URL** to the public instructions page (no sign-in required):
4. Add testers, roll out internal track, verify on devices. - `https://hesychia.ai/account-deletion/` (preferred product host)
5. Promote to closed / open / production only after internal QA passes. - or `https://chat.aimloperations.com/account-deletion/` until hesychia.ai cutover
4. **Testing → Internal testing** → create release → upload `app-release.aab`.
5. Add testers, roll out internal track, verify on devices.
6. Promote to closed / open / production only after internal QA passes.
See also [`../ANDROID.md`](../ANDROID.md) Play checklist and issue
[#103](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/103).
Each new upload needs a higher `versionCode`. Each new upload needs a higher `versionCode`.
+41 -165
View File
@@ -21,6 +21,7 @@
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mui/icons-material": "^5.16.11", "@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11", "@mui/material": "^5.16.11",
"@revenuecat/purchases-capacitor": "^11.3.2",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
@@ -33,7 +34,6 @@
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"brace-expansion": "file:vendor/brace-expansion-compat", "brace-expansion": "file:vendor/brace-expansion-compat",
"chroma-js": "^3.1.2", "chroma-js": "^3.1.2",
"docx": "^9.7.1",
"formik": "^2.4.6", "formik": "^2.4.6",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -2506,7 +2506,7 @@
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/trace-mapping": "0.3.9" "@jridgewell/trace-mapping": "0.3.9"
@@ -2519,7 +2519,7 @@
"version": "0.3.9", "version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/resolve-uri": "^3.0.3",
@@ -5239,6 +5239,24 @@
"react": ">=16.14.0" "react": ">=16.14.0"
} }
}, },
"node_modules/@revenuecat/purchases-capacitor": {
"version": "11.3.2",
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-capacitor/-/purchases-capacitor-11.3.2.tgz",
"integrity": "sha512-3T4/lcAwpbPagrT4DuXvJ+8RewzFmHf3fQJQuhVy+uTQk5HGMHNlXcm2A/UdyHtWqU+/VqWbCClPFYGnpnNXAQ==",
"license": "MIT",
"dependencies": {
"@revenuecat/purchases-typescript-internal-esm": "17.25.0"
},
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@revenuecat/purchases-typescript-internal-esm": {
"version": "17.25.0",
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal-esm/-/purchases-typescript-internal-esm-17.25.0.tgz",
"integrity": "sha512-KC4BjFaQclXqFafG1Enh7t8GSwdg8p905by7UrHq0WSJmLhOR6yXSj6WDv1iwsFSAZxPPwgozNxt+U6IOFTdCA==",
"license": "MIT"
},
"node_modules/@rollup/plugin-babel": { "node_modules/@rollup/plugin-babel": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -5590,7 +5608,6 @@
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
@@ -5983,35 +6000,34 @@
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tsconfig/node12": { "node_modules/@tsconfig/node12": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tsconfig/node14": { "node_modules/@tsconfig/node14": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tsconfig/node16": { "node_modules/@tsconfig/node16": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/aria-query": { "node_modules/@types/aria-query": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
@@ -6424,7 +6440,7 @@
"version": "18.3.5", "version": "18.3.5",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
"integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
"dev": true, "devOptional": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@@ -9035,7 +9051,7 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
@@ -9904,61 +9920,10 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/docx": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
"license": "MIT",
"dependencies": {
"@types/node": "^25.2.3",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/@types/node": {
"version": "25.9.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/docx/node_modules/undici-types": {
"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==",
"license": "MIT"
},
"node_modules/dom-accessibility-api": { "node_modules/dom-accessibility-api": {
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-converter": { "node_modules/dom-converter": {
@@ -12450,16 +12415,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -12856,12 +12811,6 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": { "node_modules/immer": {
"version": "9.0.21", "version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@@ -15779,54 +15728,6 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwt-decode": { "node_modules/jwt-decode": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
@@ -15913,15 +15814,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
@@ -16119,7 +16011,6 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
@@ -16162,7 +16053,7 @@
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true, "devOptional": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/makeerror": { "node_modules/makeerror": {
@@ -19072,7 +18963,6 @@
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -19283,7 +19173,6 @@
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"dev": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -20731,7 +20620,6 @@
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"dev": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@@ -20999,12 +20887,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -21828,6 +21710,12 @@
"postcss": "^8.2.15" "postcss": "^8.2.15"
} }
}, },
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"peer": true
},
"node_modules/stylis-plugin-rtl": { "node_modules/stylis-plugin-rtl": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz", "resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz",
@@ -22412,7 +22300,7 @@
"version": "10.9.2", "version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
@@ -22456,7 +22344,7 @@
"version": "8.3.5", "version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"acorn": "^8.11.0" "acorn": "^8.11.0"
@@ -22469,14 +22357,14 @@
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ts-node/node_modules/diff": { "node_modules/ts-node/node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true, "devOptional": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.3.1" "node": ">=0.3.1"
@@ -22661,7 +22549,6 @@
"version": "4.9.5", "version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -22909,7 +22796,7 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {
@@ -23913,16 +23800,11 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": { "node_modules/xml-js": {
"version": "1.6.11", "version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"sax": "^1.2.4" "sax": "^1.2.4"
@@ -24063,7 +23945,7 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -24106,12 +23988,6 @@
"name": "brace-expansion", "name": "brace-expansion",
"version": "5.0.8", "version": "5.0.8",
"license": "MIT" "license": "MIT"
},
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"peer": true
} }
} }
} }
+1 -1
View File
@@ -16,6 +16,7 @@
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mui/icons-material": "^5.16.11", "@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11", "@mui/material": "^5.16.11",
"@revenuecat/purchases-capacitor": "^11.3.2",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
@@ -28,7 +29,6 @@
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"brace-expansion": "file:vendor/brace-expansion-compat", "brace-expansion": "file:vendor/brace-expansion-compat",
"chroma-js": "^3.1.2", "chroma-js": "^3.1.2",
"docx": "^9.7.1",
"formik": "^2.4.6", "formik": "^2.4.6",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
+3
View File
@@ -11,6 +11,7 @@ import NotFound from './llm-fe/pages/NotFound/NotFound';
import { AuthContext } from './llm-fe/contexts/AuthContext'; import { AuthContext } from './llm-fe/contexts/AuthContext';
import TermsOfService from './llm-fe/pages/TermsOfService/TermsOfService'; import TermsOfService from './llm-fe/pages/TermsOfService/TermsOfService';
import PublicTermsOfServicePage from './llm-fe/pages/TermsOfService/PublicTermsOfServicePage'; import PublicTermsOfServicePage from './llm-fe/pages/TermsOfService/PublicTermsOfServicePage';
import AccountDeletionPage from './llm-fe/pages/AccountDeletion/AccountDeletionPage';
import SetPassword from './llm-fe/components/SetPassword/SetPassword'; import SetPassword from './llm-fe/components/SetPassword/SetPassword';
import AsyncDashboard2 from './llm-fe/pages/AsyncDashboard2/AsyncDashboard2'; import AsyncDashboard2 from './llm-fe/pages/AsyncDashboard2/AsyncDashboard2';
import FeedbackPage2 from './llm-fe/pages/FeedbackPage2/FeedbackPage2'; import FeedbackPage2 from './llm-fe/pages/FeedbackPage2/FeedbackPage2';
@@ -71,6 +72,8 @@ class App extends Component {
{/* Public legal pages (also used for post-login ToS acknowledge) */} {/* Public legal pages (also used for post-login ToS acknowledge) */}
<Route path={"/terms_of_service/"} Component={TermsOfService} /> <Route path={"/terms_of_service/"} Component={TermsOfService} />
<Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} /> <Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} />
{/* Play Console account-deletion URL (#103) — must stay public */}
<Route path={"/account-deletion/"} Component={AccountDeletionPage} />
<Route element={<ProtectedRoutes />}> <Route element={<ProtectedRoutes />}>
<Route path={"/"} index={true} Component={AsyncDashboard2} /> <Route path={"/"} index={true} Component={AsyncDashboard2} />
@@ -0,0 +1,134 @@
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import ActivityIndicator from './ActivityIndicator';
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
const theme = {
main: '#336699',
focus: '#224466',
darkMode: true,
colors: {
text: '#ffffff',
cardBackground: 'rgba(0,0,0,0.3)',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const renderIndicator = (
props: React.ComponentProps<typeof ActivityIndicator>,
) =>
render(
<ThemeProvider theme={theme as never}>
<ActivityIndicator {...props} />
</ThemeProvider>,
);
describe('ActivityIndicator (#96)', () => {
it('has status role and polite live region regardless of state', () => {
renderIndicator({ stage: null });
const status = screen.getByRole('status');
expect(status).toHaveAttribute('aria-live', 'polite');
});
it('falls back to three dots when stage is null', () => {
renderIndicator({ stage: null });
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
});
it('renders the label verbatim for unknown/future stage values', () => {
renderIndicator({ stage: 'some_future_stage', label: 'Doing something new' });
expect(screen.getByText('Doing something new')).toBeInTheDocument();
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
});
it('shows optional detail text', () => {
renderIndicator({
stage: 'searching',
label: 'Searching the web',
detail: 'query: best pizza in town',
});
expect(screen.getByText('Searching the web')).toBeInTheDocument();
expect(screen.getByText('query: best pizza in town')).toBeInTheDocument();
});
it('renders completed stages above with check marks', () => {
const history: ActivityHistoryEntry[] = [
{ stage: 'searching', label: 'Searched the web', startedAt: 0, finishedAt: 100 },
{ stage: 'reading', label: 'Read 3 sources', startedAt: 100, finishedAt: 400 },
];
renderIndicator({
stage: 'writing',
label: null,
history,
});
expect(screen.getByText('Searched the web')).toBeInTheDocument();
expect(screen.getByText('Read 3 sources')).toBeInTheDocument();
expect(screen.getAllByText('✓')).toHaveLength(2);
});
it('hides everything when interrupted', () => {
const { container } = renderIndicator({
stage: 'searching',
label: 'Searching the web',
interrupted: true,
});
expect(container).toBeEmptyDOMElement();
});
it('shows a generic label when stage is present but label is cleared (writing stage)', () => {
renderIndicator({ stage: 'writing', label: null });
expect(screen.getByText('Working…')).toBeInTheDocument();
});
describe('prefers-reduced-motion', () => {
const originalMatchMedia = window.matchMedia;
afterEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
});
it('disables the spinner animation when the user prefers reduced motion', () => {
const matchMediaMock = jest.fn().mockImplementation((query: string) => ({
matches: query.includes('prefers-reduced-motion'),
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
}));
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: matchMediaMock,
});
renderIndicator({ stage: 'searching', label: 'Searching the web' });
const spinner = screen.getByTestId('activity-spinner');
expect(spinner).toHaveAttribute('data-reduced-motion', 'true');
});
});
it('shows elapsed time after ~10s and "Still working…" after ~30s of silence', () => {
jest.useFakeTimers();
try {
renderIndicator({ stage: 'searching', label: 'Searching the web' });
act(() => {
jest.advanceTimersByTime(11_000);
});
expect(screen.getByText('11s')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(20_000);
});
expect(screen.getByText('Still working…')).toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,346 @@
import React, { useEffect, useReducer, useRef, useState } from 'react';
import styled, { css, keyframes } from 'styled-components';
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
/** Minimum time a single stage stays visible before advancing to a queued update (#96). */
const MIN_DISPLAY_MS = 400;
const ELAPSED_THRESHOLD_MS = 10_000;
const SILENCE_THRESHOLD_MS = 30_000;
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)')?.matches ?? false;
});
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
if (!mql) return undefined;
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}
// Safari < 14 fallback
mql.addListener(handler);
return () => mql.removeListener(handler);
}, []);
return reduced;
}
type QueueItem = { stage: string; label: string | null; detail: string | null };
type IndicatorState = {
current: QueueItem | null;
queue: QueueItem[];
lastIncomingStage: string | null;
};
type IndicatorAction =
| { type: 'INCOMING'; item: QueueItem | null }
| { type: 'ADVANCE' }
| { type: 'RESET' };
const initialIndicatorState: IndicatorState = {
current: null,
queue: [],
lastIncomingStage: null,
};
function indicatorReducer(state: IndicatorState, action: IndicatorAction): IndicatorState {
switch (action.type) {
case 'RESET':
return initialIndicatorState;
case 'INCOMING': {
if (!action.item) return initialIndicatorState;
// Same stage as the last update seen — refresh content in place (label/detail tweak),
// no need to enforce the min-display queue since it isn't a stage transition.
if (action.item.stage === state.lastIncomingStage) {
if (state.queue.length > 0) {
const queue = [...state.queue];
queue[queue.length - 1] = action.item;
return { ...state, queue };
}
return { ...state, current: action.item };
}
if (!state.current) {
return { current: action.item, queue: [], lastIncomingStage: action.item.stage };
}
return {
...state,
queue: [...state.queue, action.item],
lastIncomingStage: action.item.stage,
};
}
case 'ADVANCE': {
if (state.queue.length === 0) return state;
const [next, ...rest] = state.queue;
return { ...state, current: next, queue: rest };
}
default:
return state;
}
}
const spin = keyframes`
to { transform: rotate(360deg); }
`;
const bounce = keyframes`
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
`;
const Root = styled.div`
display: flex;
flex-direction: column;
gap: 0.35rem;
max-width: 100%;
min-width: 0;
width: 100%;
color: ${({ theme }) => theme.colors.text};
font-size: 0.9rem;
@media (max-width: 360px) {
font-size: 0.85rem;
}
`;
const HistoryList = styled.ul`
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.2rem;
opacity: 0.6;
min-width: 0;
`;
const HistoryItem = styled.li`
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
min-width: 0;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
`;
const CheckMark = styled.span`
flex-shrink: 0;
color: ${({ theme }) => (theme.darkMode ? '#8ee6a0' : '#2e7d32')};
font-weight: 700;
`;
const CurrentRow = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
`;
const Spinner = styled.span<{ $reducedMotion: boolean }>`
flex-shrink: 0;
width: 14px;
height: 14px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
opacity: 0.7;
${({ $reducedMotion }) =>
$reducedMotion
? css`
animation: none;
`
: css`
animation: ${spin} 0.8s linear infinite;
`}
`;
const TextColumn = styled.div`
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
`;
const LabelText = styled.span`
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
`;
const DetailText = styled.span`
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
opacity: 0.65;
margin-top: 0.1rem;
`;
const MetaText = styled.span`
flex-shrink: 0;
font-size: 0.75rem;
opacity: 0.55;
white-space: nowrap;
`;
const DotsRow = styled.div`
display: flex;
align-items: center;
padding: 0.15rem 0;
`;
const Dot = styled.span<{ $delay: string; $reducedMotion: boolean }>`
width: 8px;
height: 8px;
margin: 0 4px;
border-radius: 50%;
background: currentColor;
${({ $reducedMotion, $delay }) =>
$reducedMotion
? css`
opacity: 0.6;
`
: css`
animation: ${bounce} 1.4s infinite ease-in-out both;
animation-delay: ${$delay};
`}
`;
export type ActivityIndicatorProps = {
/** Current stage key from the latest "status" frame, or null before the first update. */
stage: string | null;
label?: string | null;
detail?: string | null;
/** Previously completed stages for this turn, oldest first. */
history?: ActivityHistoryEntry[];
/** Hides the indicator entirely once a stream is interrupted (#96). */
interrupted?: boolean;
};
const ActivityIndicator = ({
stage,
label = null,
detail = null,
history = [],
interrupted = false,
}: ActivityIndicatorProps): JSX.Element | null => {
const [state, dispatch] = useReducer(indicatorReducer, initialIndicatorState);
const reducedMotion = usePrefersReducedMotion();
const overallStartRef = useRef<number | null>(null);
const lastUpdateRef = useRef<number>(Date.now());
const currentSinceRef = useRef<number>(Date.now());
const advanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [, forceTick] = useState(0);
useEffect(() => {
if (interrupted || stage == null) {
overallStartRef.current = null;
dispatch({ type: 'RESET' });
return;
}
if (overallStartRef.current == null) {
overallStartRef.current = Date.now();
}
lastUpdateRef.current = Date.now();
dispatch({ type: 'INCOMING', item: { stage, label, detail } });
}, [stage, label, detail, interrupted]);
// Reset the per-stage clock whenever the displayed stage changes.
useEffect(() => {
currentSinceRef.current = Date.now();
}, [state.current?.stage]);
// Enforce the minimum display time per stage before advancing the queue.
useEffect(() => {
if (advanceTimerRef.current) {
clearTimeout(advanceTimerRef.current);
advanceTimerRef.current = null;
}
if (state.queue.length === 0) return undefined;
const elapsed = Date.now() - currentSinceRef.current;
const remaining = Math.max(MIN_DISPLAY_MS - elapsed, 0);
advanceTimerRef.current = setTimeout(() => {
dispatch({ type: 'ADVANCE' });
}, remaining);
return () => {
if (advanceTimerRef.current) clearTimeout(advanceTimerRef.current);
};
}, [state.queue.length]);
// Tick once a second while active so elapsed/"still working" copy stays fresh.
useEffect(() => {
if (!state.current) return undefined;
const id = setInterval(() => forceTick((n) => n + 1), 1000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [Boolean(state.current)]);
if (interrupted) return null;
const now = Date.now();
const elapsedMs = overallStartRef.current != null ? now - overallStartRef.current : 0;
const silentMs = now - lastUpdateRef.current;
const showElapsed = state.current != null && elapsedMs >= ELAPSED_THRESHOLD_MS;
const stillWorking = state.current != null && silentMs >= SILENCE_THRESHOLD_MS;
const elapsedLabel = showElapsed ? `${Math.floor(elapsedMs / 1000)}s` : null;
return (
<Root role="status" aria-live="polite">
{history.length > 0 && (
<HistoryList>
{history.map((entry, i) => (
<HistoryItem key={`${entry.stage}-${entry.startedAt}-${i}`}>
<CheckMark aria-hidden="true"></CheckMark>
<span>{entry.label}</span>
</HistoryItem>
))}
</HistoryList>
)}
{state.current ? (
<CurrentRow>
<Spinner
aria-hidden="true"
$reducedMotion={reducedMotion}
data-testid="activity-spinner"
data-reduced-motion={reducedMotion}
/>
<TextColumn>
<LabelText>
{stillWorking ? 'Still working…' : state.current.label || 'Working…'}
</LabelText>
{state.current.detail && (
<DetailText title={state.current.detail}>{state.current.detail}</DetailText>
)}
</TextColumn>
{elapsedLabel && !stillWorking && <MetaText>{elapsedLabel}</MetaText>}
</CurrentRow>
) : (
<DotsRow data-testid="activity-dots-fallback">
<Dot $delay="-0.32s" $reducedMotion={reducedMotion} />
<Dot $delay="-0.16s" $reducedMotion={reducedMotion} />
<Dot $delay="0s" $reducedMotion={reducedMotion} />
</DotsRow>
)}
</Root>
);
};
export default ActivityIndicator;
@@ -8,6 +8,9 @@ const mockGet = jest.fn();
const mockPost = jest.fn(); const mockPost = jest.fn();
const assignMock = jest.fn(); const assignMock = jest.fn();
const mockTrackEvent = jest.fn(); const mockTrackEvent = jest.fn();
const mockPurchasePlan = jest.fn();
const mockRestorePurchases = jest.fn();
const mockIsNativePlatform = jest.fn(() => false);
jest.mock('../../../axiosApi', () => ({ jest.mock('../../../axiosApi', () => ({
axiosInstance: { axiosInstance: {
@@ -27,6 +30,17 @@ jest.mock('../../utils/analytics', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args), trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
})); }));
jest.mock('../../platform/nativePlatform', () => ({
isNativePlatform: () => mockIsNativePlatform(),
}));
jest.mock('../../utils/revenueCat', () => ({
purchasePlan: (...args: unknown[]) => mockPurchasePlan(...args),
restorePurchases: (...args: unknown[]) => mockRestorePurchases(...args),
isPurchaseCancelledError: (error: { userCancelled?: boolean }) =>
Boolean(error?.userCancelled),
}));
const theme = { const theme = {
main: '#4a90e2', main: '#4a90e2',
darkMode: true, darkMode: true,
@@ -143,16 +157,16 @@ const mockFinanceGets = ({
plans?: unknown[]; plans?: unknown[];
} = {}) => { } = {}) => {
mockGet.mockImplementation((url: string) => { mockGet.mockImplementation((url: string) => {
if (url === '/finance/invoices/') { if (url === '/monetization/invoices/') {
return Promise.resolve({ data: invoices }); return Promise.resolve({ data: invoices });
} }
if (url === '/finance/payments/') { if (url === '/monetization/payments/') {
return Promise.resolve({ data: payments }); return Promise.resolve({ data: payments });
} }
if (url === '/finance/subscription/') { if (url === '/monetization/subscription/') {
return Promise.resolve({ data: subscription }); return Promise.resolve({ data: subscription });
} }
if (url === '/finance/plans/') { if (url === '/monetization/plans/') {
return Promise.resolve({ data: plans }); return Promise.resolve({ data: plans });
} }
return Promise.reject(new Error(`unexpected GET ${url}`)); return Promise.reject(new Error(`unexpected GET ${url}`));
@@ -174,6 +188,9 @@ describe('BillingSection', () => {
mockPost.mockReset(); mockPost.mockReset();
assignMock.mockReset(); assignMock.mockReset();
mockTrackEvent.mockReset(); mockTrackEvent.mockReset();
mockPurchasePlan.mockReset();
mockRestorePurchases.mockReset();
mockIsNativePlatform.mockReturnValue(false);
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
configurable: true, configurable: true,
value: { value: {
@@ -229,7 +246,7 @@ describe('BillingSection', () => {
await user.click(screen.getByRole('button', { name: /Change plan/i })); await user.click(screen.getByRole('button', { name: /Change plan/i }));
await waitFor(() => { await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/finance/portal/', { expect(mockPost).toHaveBeenCalledWith('/monetization/portal/', {
return_url: 'http://localhost/account/', return_url: 'http://localhost/account/',
}); });
}); });
@@ -304,7 +321,7 @@ describe('BillingSection', () => {
await waitFor(() => { await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith( expect(mockPost).toHaveBeenCalledWith(
'/finance/checkout/', '/monetization/checkout/',
expect.objectContaining({ expect.objectContaining({
success_url: expect.stringContaining('/billing/success'), success_url: expect.stringContaining('/billing/success'),
cancel_url: expect.stringContaining('/billing/cancel'), cancel_url: expect.stringContaining('/billing/cancel'),
@@ -345,4 +362,49 @@ describe('BillingSection', () => {
expect(await screen.findByText('No Stripe customer found')).toBeInTheDocument(); expect(await screen.findByText('No Stripe customer found')).toBeInTheDocument();
expect(assignMock).not.toHaveBeenCalled(); expect(assignMock).not.toHaveBeenCalled();
}); });
it('on native uses RevenueCat purchase and shows Restore', async () => {
mockIsNativePlatform.mockReturnValue(true);
mockFinanceGets();
mockPurchasePlan.mockResolvedValue(undefined);
const user = userEvent.setup();
renderBilling();
expect(await screen.findByTestId('restore-purchases')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Complete payment/i }));
await waitFor(() => {
expect(mockPurchasePlan).toHaveBeenCalled();
});
expect(mockPost).not.toHaveBeenCalledWith(
'/monetization/checkout/',
expect.anything()
);
});
it('shows provider badge for RevenueCat invoices', async () => {
mockFinanceGets({
invoices: [
{
...paidInvoice,
provider: 'revenuecat',
stripe_subscription_id: null,
revenuecat_store: 'PLAY_STORE',
hosted_invoice_url: '',
description: 'Store IAP (PLAY_STORE) — founders — INITIAL_PURCHASE',
},
],
subscription: {
...foundersSubscription,
source: 'revenuecat',
stripe_subscription_id: '',
},
});
renderBilling();
expect(await screen.findByText('Play Store')).toBeInTheDocument();
expect(screen.getByText(/Store IAP/)).toBeInTheDocument();
});
}); });
@@ -12,12 +12,21 @@ import {
formatTokenCount, formatTokenCount,
higherSelectablePlans, higherSelectablePlans,
humanizeStatus, humanizeStatus,
invoiceProviderLabel,
isComplimentarySubscription, isComplimentarySubscription,
isStoreSubscription,
isStripeSubscription,
otherSelectablePlans, otherSelectablePlans,
pickPrimaryInvoice, pickPrimaryInvoice,
SubscriptionMe, SubscriptionMe,
SubscriptionPlanInfo, SubscriptionPlanInfo,
} from '../../utils/finance'; } from '../../utils/finance';
import { isNativePlatform } from '../../platform/nativePlatform';
import {
isPurchaseCancelledError,
purchasePlan,
restorePurchases,
} from '../../utils/revenueCat';
const GlassCard = styled.div` const GlassCard = styled.div`
background: ${({ theme }) => theme.colors.cardBackground}; background: ${({ theme }) => theme.colors.cardBackground};
@@ -233,6 +242,14 @@ const InvoiceLink = styled.a`
} }
`; `;
const ProviderBadge = styled.span`
display: inline-block;
font-size: 0.8rem;
font-weight: 600;
opacity: 0.85;
white-space: nowrap;
`;
function apiErrorMessage(error: unknown, fallback: string): string { function apiErrorMessage(error: unknown, fallback: string): string {
const axiosError = error as { const axiosError = error as {
response?: { data?: { detail?: string } }; response?: { data?: { detail?: string } };
@@ -241,9 +258,19 @@ function apiErrorMessage(error: unknown, fallback: string): string {
return axiosError.response?.data?.detail || axiosError.message || fallback; return axiosError.response?.data?.detail || axiosError.message || fallback;
} }
function storeManageLabel(): string {
if (typeof window === 'undefined') return 'App Store / Play Store';
const Cap = window.Capacitor as { getPlatform?: () => string } | undefined;
const platform = Cap?.getPlatform?.();
if (platform === 'ios') return 'App Store';
if (platform === 'android') return 'Play Store';
return 'App Store / Play Store';
}
type PortalIntent = 'manage' | 'upgrade' | 'change' | 'cancel'; type PortalIntent = 'manage' | 'upgrade' | 'change' | 'cancel';
const BillingSection = (): JSX.Element => { const BillingSection = (): JSX.Element => {
const native = isNativePlatform();
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]); const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
const [payments, setPayments] = useState<FinancePayment[]>([]); const [payments, setPayments] = useState<FinancePayment[]>([]);
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null); const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
@@ -251,8 +278,10 @@ const BillingSection = (): JSX.Element => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [listError, setListError] = useState(''); const [listError, setListError] = useState('');
const [actionError, setActionError] = useState(''); const [actionError, setActionError] = useState('');
const [actionNotice, setActionNotice] = useState('');
const [portalLoading, setPortalLoading] = useState(false); const [portalLoading, setPortalLoading] = useState(false);
const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState<string | null>(null); const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState<string | null>(null);
const [restoreLoading, setRestoreLoading] = useState(false);
const [showPlanPicker, setShowPlanPicker] = useState(false); const [showPlanPicker, setShowPlanPicker] = useState(false);
const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change'); const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change');
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
@@ -263,10 +292,10 @@ const BillingSection = (): JSX.Element => {
try { try {
const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] = const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] =
await Promise.all([ await Promise.all([
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'), axiosInstance.get<FinanceInvoice[]>('/monetization/invoices/'),
axiosInstance.get<FinancePayment[]>('/finance/payments/'), axiosInstance.get<FinancePayment[]>('/monetization/payments/'),
axiosInstance.get<SubscriptionMe>('/finance/subscription/'), axiosInstance.get<SubscriptionMe>('/monetization/subscription/'),
axiosInstance.get<SubscriptionPlanInfo[]>('/finance/plans/'), axiosInstance.get<SubscriptionPlanInfo[]>('/monetization/plans/'),
]); ]);
setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []); setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []);
setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []); setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []);
@@ -293,6 +322,16 @@ const BillingSection = (): JSX.Element => {
() => isComplimentarySubscription(subscription, hasPortalAccess), () => isComplimentarySubscription(subscription, hasPortalAccess),
[subscription, hasPortalAccess] [subscription, hasPortalAccess]
); );
const storeSub = useMemo(
() => isStoreSubscription(subscription?.source),
[subscription?.source]
);
const stripeSub = useMemo(
() => isStripeSubscription(subscription?.source) || hasPortalAccess,
[subscription?.source, hasPortalAccess]
);
const showStripeManage = !complimentary && stripeSub && hasPortalAccess;
const showStoreManage = !complimentary && (storeSub || (native && !showStripeManage && Boolean(subscription?.plan) && !subscription?.needs_checkout));
const upgradePlans = useMemo( const upgradePlans = useMemo(
() => higherSelectablePlans(plans, subscription?.plan), () => higherSelectablePlans(plans, subscription?.plan),
[plans, subscription?.plan] [plans, subscription?.plan]
@@ -306,6 +345,7 @@ const BillingSection = (): JSX.Element => {
subscription?.current_period_end || primaryInvoice?.period_end || null; subscription?.current_period_end || primaryInvoice?.period_end || null;
return formatBillingDate(end); return formatBillingDate(end);
}, [subscription?.current_period_end, primaryInvoice?.period_end]); }, [subscription?.current_period_end, primaryInvoice?.period_end]);
const storeLabel = useMemo(() => storeManageLabel(), []);
const historyRows = useMemo(() => { const historyRows = useMemo(() => {
if (invoices.length) { if (invoices.length) {
@@ -313,6 +353,7 @@ const BillingSection = (): JSX.Element => {
key: `invoice-${invoice.id}`, key: `invoice-${invoice.id}`,
date: invoice.created, date: invoice.created,
description: invoice.description || 'Invoice', description: invoice.description || 'Invoice',
provider: invoiceProviderLabel(invoice),
amount: formatMoneyCents( amount: formatMoneyCents(
invoice.amount_paid || invoice.amount_due, invoice.amount_paid || invoice.amount_due,
invoice.currency invoice.currency
@@ -325,6 +366,12 @@ const BillingSection = (): JSX.Element => {
key: `payment-${payment.id}`, key: `payment-${payment.id}`,
date: payment.paid_at || payment.created, date: payment.paid_at || payment.created,
description: 'Payment', description: 'Payment',
provider:
(payment.provider || '').toLowerCase() === 'revenuecat'
? 'Store'
: (payment.provider || '').toLowerCase() === 'stripe'
? 'Stripe'
: payment.provider || '—',
amount: formatMoneyCents(payment.amount, payment.currency), amount: formatMoneyCents(payment.amount, payment.currency),
status: humanizeStatus(payment.status), status: humanizeStatus(payment.status),
url: '', url: '',
@@ -333,11 +380,12 @@ const BillingSection = (): JSX.Element => {
const openPortal = async (intent: PortalIntent) => { const openPortal = async (intent: PortalIntent) => {
setActionError(''); setActionError('');
setActionNotice('');
setPortalLoading(true); setPortalLoading(true);
try { try {
const returnUrl = `${window.location.origin}/account/`; const returnUrl = `${window.location.origin}/account/`;
const response = await axiosInstance.post<{ portal_url: string }>( const response = await axiosInstance.post<{ portal_url: string }>(
'/finance/portal/', '/monetization/portal/',
{ return_url: returnUrl } { return_url: returnUrl }
); );
const portalUrl = response.data?.portal_url; const portalUrl = response.data?.portal_url;
@@ -356,8 +404,55 @@ const BillingSection = (): JSX.Element => {
} }
}; };
const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => { const handleNativePurchase = async (planSlug?: string, source = 'account_billing') => {
setActionError(''); setActionError('');
setActionNotice('');
setCheckoutLoadingSlug(planSlug || '__default__');
try {
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, {
source,
...(planSlug ? { plan_slug: planSlug } : {}),
});
await purchasePlan(planSlug);
setActionNotice(
'Purchase submitted. Entitlements update after the store confirms — tap Refresh shortly.'
);
await loadBilling();
} catch (error: unknown) {
if (isPurchaseCancelledError(error)) {
setActionNotice('Purchase cancelled.');
return;
}
setActionError(
apiErrorMessage(error, 'Store purchase failed. Try again or Restore purchases.')
);
} finally {
setCheckoutLoadingSlug(null);
}
};
const handleRestore = async () => {
setActionError('');
setActionNotice('');
setRestoreLoading(true);
try {
await restorePurchases();
setActionNotice('Purchases restored. Refreshing billing…');
await loadBilling();
} catch (error: unknown) {
setActionError(apiErrorMessage(error, 'Could not restore purchases. Try again.'));
} finally {
setRestoreLoading(false);
}
};
const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => {
if (native) {
await handleNativePurchase(planSlug, source);
return;
}
setActionError('');
setActionNotice('');
setCheckoutLoadingSlug(planSlug || '__default__'); setCheckoutLoadingSlug(planSlug || '__default__');
try { try {
const { success_url, cancel_url } = checkoutReturnUrls(); const { success_url, cancel_url } = checkoutReturnUrls();
@@ -366,7 +461,7 @@ const BillingSection = (): JSX.Element => {
...(planSlug ? { plan_slug: planSlug } : {}), ...(planSlug ? { plan_slug: planSlug } : {}),
}); });
const response = await axiosInstance.post<{ checkout_url: string }>( const response = await axiosInstance.post<{ checkout_url: string }>(
'/finance/checkout/', '/monetization/checkout/',
{ {
success_url, success_url,
cancel_url, cancel_url,
@@ -395,6 +490,10 @@ const BillingSection = (): JSX.Element => {
setShowPlanPicker(true); setShowPlanPicker(true);
return; return;
} }
if (native && (storeSub || showStoreManage)) {
void handleNativePurchase(undefined, 'account_upgrade');
return;
}
void openPortal('upgrade'); void openPortal('upgrade');
}; };
@@ -405,10 +504,24 @@ const BillingSection = (): JSX.Element => {
setShowPlanPicker(true); setShowPlanPicker(true);
return; return;
} }
if (native && (storeSub || showStoreManage)) {
setActionNotice(
`Change or cancel your plan in ${storeLabel} subscription settings.`
);
return;
}
void openPortal('change'); void openPortal('change');
}; };
const handleConfirmCancel = async () => { const handleConfirmCancel = async () => {
if (native && (storeSub || showStoreManage) && !showStripeManage) {
trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'store' });
setCancelConfirmOpen(false);
setActionNotice(
`Open ${storeLabel} → Subscriptions to cancel. Access usually continues until the period end.`
);
return;
}
trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'portal' }); trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'portal' });
setCancelConfirmOpen(false); setCancelConfirmOpen(false);
await openPortal('cancel'); await openPortal('cancel');
@@ -422,7 +535,6 @@ const BillingSection = (): JSX.Element => {
source: 'plan_picker', source: 'plan_picker',
plan_slug: plan.slug, plan_slug: plan.slug,
}); });
// New higher tier via Checkout when selectable; portal otherwise.
await handleStartCheckout(plan.slug, 'account_upgrade'); await handleStartCheckout(plan.slug, 'account_upgrade');
return; return;
} }
@@ -430,7 +542,12 @@ const BillingSection = (): JSX.Element => {
source: 'plan_picker', source: 'plan_picker',
plan_slug: plan.slug, plan_slug: plan.slug,
}); });
// Existing subscribers change plans in the Stripe portal (proration / PCI). if (native && (storeSub || showStoreManage) && !showStripeManage) {
setShowPlanPicker(false);
await handleNativePurchase(plan.slug, 'account_change');
return;
}
// Existing Stripe subscribers change plans in the portal (proration / PCI).
setShowPlanPicker(false); setShowPlanPicker(false);
await openPortal('change'); await openPortal('change');
}; };
@@ -525,7 +642,7 @@ const BillingSection = (): JSX.Element => {
{!loading && !listError && ( {!loading && !listError && (
<ButtonRow> <ButtonRow>
{hasPortalAccess && !complimentary ? ( {showStripeManage ? (
<> <>
<StyledButton <StyledButton
type="button" type="button"
@@ -564,14 +681,74 @@ const BillingSection = (): JSX.Element => {
Complimentary access no payment required. Plan changes and Complimentary access no payment required. Plan changes and
cancellation are not available for this account. cancellation are not available for this account.
</BodyText> </BodyText>
) : showStoreManage ? (
<>
<StyledButton
type="button"
onClick={handleUpgradeClick}
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
>
Upgrade
</StyledButton>
<SecondaryButton
type="button"
onClick={handleChangePlanClick}
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
>
Change plan
</SecondaryButton>
{!subscription?.cancel_at_period_end &&
subscription?.status !== 'canceled' ? (
<DangerButton
type="button"
onClick={() => setCancelConfirmOpen(true)}
disabled={Boolean(checkoutLoadingSlug)}
>
Cancel
</DangerButton>
) : null}
<SecondaryButton
type="button"
onClick={() =>
setActionNotice(
`Manage billing in ${storeLabel} → Subscriptions. Changes sync here after refresh.`
)
}
data-testid="store-manage-hint"
>
Manage in {storeLabel}
</SecondaryButton>
{native ? (
<SecondaryButton
type="button"
onClick={handleRestore}
disabled={restoreLoading || Boolean(checkoutLoadingSlug)}
data-testid="restore-purchases"
>
{restoreLoading ? 'Restoring…' : 'Restore purchases'}
</SecondaryButton>
) : null}
</>
) : ( ) : (
<>
<StyledButton <StyledButton
type="button" type="button"
onClick={() => handleStartCheckout()} onClick={() => handleStartCheckout()}
disabled={Boolean(checkoutLoadingSlug)} disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
> >
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'} {checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
</StyledButton> </StyledButton>
{native ? (
<SecondaryButton
type="button"
onClick={handleRestore}
disabled={restoreLoading || Boolean(checkoutLoadingSlug)}
data-testid="restore-purchases"
>
{restoreLoading ? 'Restoring…' : 'Restore purchases'}
</SecondaryButton>
) : null}
</>
)} )}
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}> <SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
Refresh Refresh
@@ -583,7 +760,11 @@ const BillingSection = (): JSX.Element => {
<div data-testid="plan-picker"> <div data-testid="plan-picker">
<BodyText style={{ marginTop: '1.25rem', marginBottom: 0 }}> <BodyText style={{ marginTop: '1.25rem', marginBottom: 0 }}>
{planPickerMode === 'upgrade' {planPickerMode === 'upgrade'
? 'Choose a higher plan. Checkout opens securely in Stripe.' ? native && !showStripeManage
? 'Choose a higher plan. Purchase completes in the app store.'
: 'Choose a higher plan. Checkout opens securely in Stripe.'
: native && !showStripeManage
? 'Select another plan to purchase via the app store.'
: 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'} : 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'}
</BodyText> </BodyText>
<PlanList> <PlanList>
@@ -624,6 +805,9 @@ const BillingSection = (): JSX.Element => {
</div> </div>
) : null} ) : null}
{actionNotice ? (
<NoticeText data-testid="billing-action-notice">{actionNotice}</NoticeText>
) : null}
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null} {actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
</GlassCard> </GlassCard>
@@ -641,6 +825,7 @@ const BillingSection = (): JSX.Element => {
<thead> <thead>
<tr> <tr>
<Th>Date</Th> <Th>Date</Th>
<Th>Provider</Th>
<Th>Description</Th> <Th>Description</Th>
<Th>Amount</Th> <Th>Amount</Th>
<Th>Status</Th> <Th>Status</Th>
@@ -651,6 +836,9 @@ const BillingSection = (): JSX.Element => {
{historyRows.map((row) => ( {historyRows.map((row) => (
<tr key={row.key}> <tr key={row.key}>
<Td>{formatBillingDate(row.date)}</Td> <Td>{formatBillingDate(row.date)}</Td>
<Td>
<ProviderBadge>{row.provider}</ProviderBadge>
</Td>
<Td>{row.description}</Td> <Td>{row.description}</Td>
<Td>{row.amount}</Td> <Td>{row.amount}</Td>
<Td>{row.status}</Td> <Td>{row.status}</Td>
@@ -685,9 +873,9 @@ const BillingSection = (): JSX.Element => {
> >
<ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle> <ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle>
<BodyText> <BodyText>
You will finish canceling in the Stripe customer portal. Access typically {native && (storeSub || showStoreManage) && !showStripeManage
continues until the end of the current billing period ? `You will cancel in ${storeLabel} subscription settings. Access typically continues until the end of the current billing period${periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.`
{periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}. : `You will finish canceling in the Stripe customer portal. Access typically continues until the end of the current billing period${periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.`}
</BodyText> </BodyText>
<ButtonRow> <ButtonRow>
<DangerButton <DangerButton
@@ -695,7 +883,11 @@ const BillingSection = (): JSX.Element => {
onClick={handleConfirmCancel} onClick={handleConfirmCancel}
disabled={portalLoading} disabled={portalLoading}
> >
{portalLoading ? 'Opening…' : 'Continue to cancel'} {native && (storeSub || showStoreManage) && !showStripeManage
? 'Got it'
: portalLoading
? 'Opening…'
: 'Continue to cancel'}
</DangerButton> </DangerButton>
<SecondaryButton <SecondaryButton
type="button" type="button"
@@ -36,7 +36,7 @@ describe('ConversationDetailCard citations (#98)', () => {
expect(out).toContain('```\n[9]\n```'); expect(out).toContain('```\n[9]\n```');
}); });
it('renders Sources list from citations prop', () => { it('does not render Sources list card', () => {
renderCard({ renderCard({
message: 'Answer with [1]', message: 'Answer with [1]',
user_created: false, user_created: false,
@@ -49,24 +49,13 @@ describe('ConversationDetailCard citations (#98)', () => {
}, },
], ],
}); });
expect(screen.getByLabelText('Sources')).toBeInTheDocument();
const link = screen.getByRole('link', { name: 'Example Source' });
expect(link).toHaveAttribute('href', 'https://example.com/a');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(link).toHaveAttribute('target', '_blank');
});
it('renders nothing for Sources when citations empty', () => {
renderCard({
message: 'No sources here',
user_created: false,
citations: [],
});
expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Source 1' })).toBeInTheDocument();
}); });
it('highlights source when inline citation clicked', async () => { it('opens citation URL in a new tab when inline marker clicked', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
renderCard({ renderCard({
message: 'See [1]', message: 'See [1]',
user_created: false, user_created: false,
@@ -75,6 +64,11 @@ describe('ConversationDetailCard citations (#98)', () => {
], ],
}); });
await user.click(screen.getByRole('button', { name: 'Source 1' })); await user.click(screen.getByRole('button', { name: 'Source 1' }));
expect(document.getElementById('citation-source-1')).toBeInTheDocument(); expect(openSpy).toHaveBeenCalledWith(
'https://example.com',
'_blank',
'noopener,noreferrer',
);
openSpy.mockRestore();
}); });
}); });
@@ -116,4 +116,34 @@ describe('ConversationDetailCard', () => {
expect(agentBubble).not.toBeNull(); expect(agentBubble).not.toBeNull();
expect(getComputedStyle(agentBubble!).color).toBe('rgb(255, 255, 255)'); expect(getComputedStyle(agentBubble!).color).toBe('rgb(255, 255, 255)');
}); });
it('renders activity label instead of dots when stage is set (#96)', () => {
render(
<MemoryRouter>
<ThemeProvider theme={darkTheme as never}>
<ConversationDetailCard
message=""
user_created={false}
activityStage="searching"
activityLabel="Searching the web"
activityDetail="Taylor Swift wedding"
/>
</ThemeProvider>
</MemoryRouter>,
);
expect(screen.getByText('Searching the web')).toBeInTheDocument();
expect(screen.getByText('Taylor Swift wedding')).toBeInTheDocument();
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
});
it('falls back to dots when empty message has no activity stage (#96)', () => {
render(
<MemoryRouter>
<ThemeProvider theme={darkTheme as never}>
<ConversationDetailCard message="" user_created={false} />
</ThemeProvider>
</MemoryRouter>,
);
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
});
}); });
@@ -1,13 +1,13 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo } from 'react';
import Markdown from 'markdown-to-jsx'; import Markdown from 'markdown-to-jsx';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import styled, { keyframes } from 'styled-components'; import styled, { keyframes } from 'styled-components';
import { isRagFeatureNotAllowed, parseChatErrorPayload } from '../../utils/chatErrors'; import { isRagFeatureNotAllowed, parseChatErrorPayload } from '../../utils/chatErrors';
import type { Citation } from '../../utils/wsFrames'; import type { ActivityHistoryEntry, Citation } from '../../utils/wsFrames';
import type { PromptRating } from '../../utils/promptFeedback'; import type { PromptRating } from '../../utils/promptFeedback';
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock'; import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
import SourcesList from '../SourcesList/SourcesList';
import MessageActions from '../MessageActions/MessageActions'; import MessageActions from '../MessageActions/MessageActions';
import ActivityIndicator from '../ActivityIndicator/ActivityIndicator';
const fadeIn = keyframes` const fadeIn = keyframes`
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(10px); }
@@ -95,30 +95,6 @@ const Bubble = styled.div<{ $isUser: boolean }>`
} }
`; `;
const LoadingDot = styled.div`
width: 8px;
height: 8px;
background: currentColor;
border-radius: 50%;
margin: 0 4px;
animation: bounce 1.4s infinite ease-in-out both;
&:nth-child(1) { animation-delay: -0.32s; }
&:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
`;
const LoadingContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
`;
const UpgradeNotice = styled.div` const UpgradeNotice = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -188,6 +164,12 @@ type ConversationDetailCardProps = {
createdTimestamp?: Date | string | null; createdTimestamp?: Date | string | null;
initialRating?: PromptRating | null; initialRating?: PromptRating | null;
onRatingChange?: (rating: PromptRating | null) => void; onRatingChange?: (rating: PromptRating | null) => void;
/** Live activity status for the empty-message (streaming placeholder) bubble (#96). */
activityStage?: string | null;
activityLabel?: string | null;
activityDetail?: string | null;
activityHistory?: ActivityHistoryEntry[];
activityInterrupted?: boolean;
}; };
const MyPlot = ({ format, image }: { format: string; image: string }) => { const MyPlot = ({ format, image }: { format: string; image: string }) => {
@@ -220,9 +202,12 @@ const ConversationDetailCard = ({
createdTimestamp = null, createdTimestamp = null,
initialRating = null, initialRating = null,
onRatingChange, onRatingChange,
activityStage = null,
activityLabel = null,
activityDetail = null,
activityHistory = [],
activityInterrupted = false,
}: ConversationDetailCardProps): JSX.Element => { }: ConversationDetailCardProps): JSX.Element => {
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
const CitationMark = useCallback( const CitationMark = useCallback(
({ indices }: { indices?: string }) => { ({ indices }: { indices?: string }) => {
const list = (indices || '') const list = (indices || '')
@@ -241,12 +226,9 @@ const ConversationDetailCard = ({
aria-label={`Source ${index}`} aria-label={`Source ${index}`}
title={citation?.title || `Source ${index}`} title={citation?.title || `Source ${index}`}
onClick={() => { onClick={() => {
setHighlightIndex(index);
if (citation?.url) { if (citation?.url) {
// Prefer scroll/highlight; URL still available from Sources list window.open(citation.url, '_blank', 'noopener,noreferrer');
} }
const el = document.getElementById(`citation-source-${index}`);
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
}} }}
> >
[{index}] [{index}]
@@ -269,11 +251,13 @@ const ConversationDetailCard = ({
return ( return (
<MessageContainer $isUser={false}> <MessageContainer $isUser={false}>
<Bubble $isUser={false}> <Bubble $isUser={false}>
<LoadingContainer> <ActivityIndicator
<LoadingDot /> stage={activityStage}
<LoadingDot /> label={activityLabel}
<LoadingDot /> detail={activityDetail}
</LoadingContainer> history={activityHistory}
interrupted={activityInterrupted}
/>
</Bubble> </Bubble>
</MessageContainer> </MessageContainer>
); );
@@ -356,10 +340,6 @@ const ConversationDetailCard = ({
</Markdown> </Markdown>
</Bubble> </Bubble>
{!user_created && citations.length > 0 && (
<SourcesList citations={citations} highlightIndex={highlightIndex} />
)}
<MessageActions <MessageActions
promptId={promptId} promptId={promptId}
rawMarkdown={rawForCopy} rawMarkdown={rawForCopy}
@@ -44,6 +44,10 @@ jest.mock('../../utils/analytics', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args), trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
})); }));
jest.mock('../../utils/revenueCat', () => ({
logOutRevenueCat: () => Promise.resolve(),
}));
const theme = { const theme = {
main: '#4a90e2', main: '#4a90e2',
darkMode: true, darkMode: true,
@@ -6,6 +6,7 @@ import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
import { AccountContext } from '../../contexts/AccountContext'; import { AccountContext } from '../../contexts/AccountContext';
import { AuthContext } from '../../contexts/AuthContext'; import { AuthContext } from '../../contexts/AuthContext';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
import { logOutRevenueCat } from '../../utils/revenueCat';
const GlassCard = styled.div` const GlassCard = styled.div`
background: ${({ theme }) => theme.colors.cardBackground}; background: ${({ theme }) => theme.colors.cardBackground};
@@ -180,6 +181,7 @@ const DeleteAccountSection = (): JSX.Element => {
applyAccessToken(null); applyAccessToken(null);
setAuthentication(false); setAuthentication(false);
setAccount(undefined); setAccount(undefined);
void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err));
navigate('/signin/'); navigate('/signin/');
} catch (err: unknown) { } catch (err: unknown) {
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED); trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED);
@@ -19,6 +19,10 @@ jest.mock('../../../axiosApi', () => ({
applyAccessToken: jest.fn(), applyAccessToken: jest.fn(),
})); }));
jest.mock('../../utils/revenueCat', () => ({
logOutRevenueCat: () => Promise.resolve(),
}));
const theme = { const theme = {
main: '#4a90e2', main: '#4a90e2',
focus: '#224466', focus: '#224466',
@@ -82,7 +86,7 @@ describe('Header2 (#81 subscription-aware Documents nav link)', () => {
renderHeader(); renderHeader();
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/finance/subscription/')); await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/monetization/subscription/'));
expect(screen.queryByText('Documents')).not.toBeInTheDocument(); expect(screen.queryByText('Documents')).not.toBeInTheDocument();
}); });
@@ -6,6 +6,7 @@ import { AccountContext } from '../../contexts/AccountContext';
import { applyAccessToken, axiosInstance } from '../../../axiosApi'; import { applyAccessToken, axiosInstance } from '../../../axiosApi';
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage'; import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
import { useSubscription } from '../../hooks/useSubscription'; import { useSubscription } from '../../hooks/useSubscription';
import { logOutRevenueCat } from '../../utils/revenueCat';
import hesychiaMark from '../../assets/brand/hesychia-mark.png'; import hesychiaMark from '../../assets/brand/hesychia-mark.png';
const HeaderContainer = styled.header` const HeaderContainer = styled.header`
@@ -230,6 +231,7 @@ const Header2 = ({
applyAccessToken(null); applyAccessToken(null);
setAuthentication(false) setAuthentication(false)
setAccount(undefined); setAccount(undefined);
void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err));
navigate('/signin/') navigate('/signin/')
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -309,7 +309,6 @@ const MessageActions = ({
{( {(
[ [
['pdf', 'PDF'], ['pdf', 'PDF'],
['docx', 'Word (.docx)'],
['csv', 'CSV'], ['csv', 'CSV'],
['xlsx', 'Excel (.xlsx)'], ['xlsx', 'Excel (.xlsx)'],
['txt', 'Plain text'], ['txt', 'Plain text'],
@@ -1,138 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import type { Citation } from '../../utils/wsFrames';
const SourcesRoot = styled.aside`
margin-top: 0.65rem;
max-width: 80%;
min-width: 0;
width: 100%;
padding: 0.65rem 0.85rem;
border-radius: 0.75rem;
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
background: ${({ theme }) =>
theme.darkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)'};
color: ${({ theme }) => theme.colors.text};
@media (max-width: 768px) {
max-width: 92%;
}
`;
const SourcesTitle = styled.h4`
margin: 0 0 0.5rem;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
opacity: 0.75;
`;
const SourceList = styled.ol`
margin: 0;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
`;
const SourceItem = styled.li<{ $highlight: boolean }>`
font-size: 0.85rem;
line-height: 1.4;
border-radius: 0.35rem;
padding: 0.15rem 0.25rem;
outline: ${({ $highlight, theme }) =>
$highlight ? `2px solid ${theme.main}` : 'none'};
background: ${({ $highlight, theme }) =>
$highlight
? theme.darkMode
? 'rgba(255,255,255,0.08)'
: 'rgba(0,0,0,0.06)'
: 'transparent'};
transition: background 0.2s ease, outline 0.2s ease;
`;
const SourceLink = styled.a`
color: ${({ theme }) => (theme.darkMode ? '#a0c4ff' : theme.main)};
text-decoration: underline;
word-break: break-word;
`;
const SourceMeta = styled.span`
display: block;
font-size: 0.75rem;
opacity: 0.65;
margin-top: 0.1rem;
`;
function domainFromUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
type SourcesListProps = {
citations: Citation[];
highlightIndex?: number | null;
};
const SourcesList = ({
citations,
highlightIndex = null,
}: SourcesListProps): JSX.Element | null => {
const itemRefs = useRef<Record<number, HTMLLIElement | null>>({});
const [active, setActive] = useState<number | null>(highlightIndex);
useEffect(() => {
setActive(highlightIndex ?? null);
if (highlightIndex == null) return;
const el = itemRefs.current[highlightIndex];
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
}, [highlightIndex]);
if (!citations.length) return null;
const sorted = [...citations].sort((a, b) => a.index - b.index);
return (
<SourcesRoot aria-label="Sources">
<SourcesTitle>Sources</SourcesTitle>
<SourceList>
{sorted.map((citation) => {
const domain = domainFromUrl(citation.url);
return (
<SourceItem
key={citation.index}
id={`citation-source-${citation.index}`}
$highlight={active === citation.index}
ref={(node) => {
itemRefs.current[citation.index] = node;
}}
>
{citation.url ? (
<SourceLink
href={citation.url}
target="_blank"
rel="noopener noreferrer"
>
{citation.title || `Source ${citation.index}`}
</SourceLink>
) : (
<span>{citation.title || `Source ${citation.index}`}</span>
)}
{(citation.published_at || domain) && (
<SourceMeta>
{[citation.published_at, domain].filter(Boolean).join(' · ')}
</SourceMeta>
)}
</SourceItem>
);
})}
</SourceList>
</SourcesRoot>
);
};
export default SourcesList;
@@ -1,5 +1,7 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlined from '@mui/icons-material/InfoOutlined';
import { import {
useMaterialUIController, useMaterialUIController,
setDarkMode, setDarkMode,
@@ -48,6 +50,16 @@ const SettingLabel = styled.span`
font-size: 1.1rem; font-size: 1.1rem;
color: ${({ theme }) => theme.colors.text}; color: ${({ theme }) => theme.colors.text};
font-weight: 500; font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.4rem;
`;
const InfoIcon = styled(InfoOutlined)`
font-size: 1rem !important;
opacity: 0.7;
cursor: help;
vertical-align: middle;
`; `;
const ToggleSwitch = styled.label` const ToggleSwitch = styled.label`
@@ -93,6 +105,11 @@ const Checkbox = styled.input`
&:checked + ${Slider}:before { &:checked + ${Slider}:before {
transform: translateX(26px); transform: translateX(26px);
} }
&:disabled + ${Slider} {
opacity: 0.5;
cursor: not-allowed;
}
`; `;
const ColorPickerContainer = styled.div` const ColorPickerContainer = styled.div`
@@ -115,6 +132,23 @@ const ColorButton = styled.button<{ color: string; isSelected: boolean }>`
} }
`; `;
const ErrorText = styled.p`
color: #ff6b6b;
margin: 0.75rem 0 0 0;
font-size: 0.95rem;
`;
const CONTEXT_TOOLTIP =
'This will use previous conversations to better customize the experience.';
function prefsErrorMessage(error: unknown, fallback: string): string {
const axiosError = error as {
response?: { data?: { detail?: string } };
message?: string;
};
return axiosError.response?.data?.detail || axiosError.message || fallback;
}
type PreferencesValues = { type PreferencesValues = {
order: boolean; order: boolean;
} }
@@ -128,6 +162,10 @@ const ThemeSettingsCard = (): JSX.Element => {
const themeColors = Object.keys(palettes); const themeColors = Object.keys(palettes);
const [order, setOrder] = useState<boolean>(true); const [order, setOrder] = useState<boolean>(true);
const [useConversationContext, setUseConversationContext] = useState<boolean>(false);
const [prefsLoading, setPrefsLoading] = useState<boolean>(true);
const [contextSaving, setContextSaving] = useState<boolean>(false);
const [prefsError, setPrefsError] = useState<string>('');
const { updatePreferences } = useContext(PreferenceContext); const { updatePreferences } = useContext(PreferenceContext);
const handleConversationOrder = async ({ order }: PreferencesValues): Promise<void> => { const handleConversationOrder = async ({ order }: PreferencesValues): Promise<void> => {
@@ -141,17 +179,50 @@ const ThemeSettingsCard = (): JSX.Element => {
} }
} }
async function getConversationOrder() { async function getConversationPreferences() {
setPrefsLoading(true);
setPrefsError('');
try { try {
const { data, }: AxiosResponse<PreferencesType> = await axiosInstance.get(`/conversation_preferences`); const { data }: AxiosResponse<PreferencesType> = await axiosInstance.get(
`/conversation_preferences`
);
setOrder(data.order); setOrder(data.order);
setUseConversationContext(Boolean(data.use_conversation_context));
} catch (error) { } catch (error) {
console.log(error) setPrefsError(
prefsErrorMessage(error, 'Could not load account preferences.')
);
} finally {
setPrefsLoading(false);
} }
} }
const handleConversationContext = async (enabled: boolean): Promise<void> => {
const previous = useConversationContext;
setUseConversationContext(enabled);
setContextSaving(true);
setPrefsError('');
try {
const { data }: AxiosResponse<PreferencesType> = await axiosInstance.post(
'/conversation_preferences',
{ use_conversation_context: enabled }
);
setUseConversationContext(Boolean(data.use_conversation_context));
} catch (error) {
setUseConversationContext(previous);
setPrefsError(
prefsErrorMessage(
error,
'Could not update conversation context setting. Try again.'
)
);
} finally {
setContextSaving(false);
}
};
useEffect(() => { useEffect(() => {
getConversationOrder(); getConversationPreferences();
}, []) }, [])
const handleDarkMode = () => setDarkMode(dispatch, !darkMode); const handleDarkMode = () => setDarkMode(dispatch, !darkMode);
@@ -193,6 +264,7 @@ const ThemeSettingsCard = (): JSX.Element => {
<Checkbox <Checkbox
type="checkbox" type="checkbox"
checked={order} checked={order}
disabled={prefsLoading}
onChange={() => { onChange={() => {
setOrder(!order); setOrder(!order);
handleConversationOrder({ order: !order }) handleConversationOrder({ order: !order })
@@ -202,6 +274,30 @@ const ThemeSettingsCard = (): JSX.Element => {
</ToggleSwitch> </ToggleSwitch>
</SettingRow> </SettingRow>
<SettingRow>
<SettingLabel>
Use previous conversations
<Tooltip title={CONTEXT_TOOLTIP}>
<span>
<InfoIcon aria-label={CONTEXT_TOOLTIP} />
</span>
</Tooltip>
</SettingLabel>
<ToggleSwitch>
<Checkbox
type="checkbox"
checked={useConversationContext}
disabled={prefsLoading || contextSaving}
onChange={() => {
handleConversationContext(!useConversationContext);
}}
/>
<Slider />
</ToggleSwitch>
</SettingRow>
{prefsError ? <ErrorText role="alert">{prefsError}</ErrorText> : null}
</GlassCard> </GlassCard>
); );
}; };
@@ -174,8 +174,8 @@ const UsageSummaryCard = (): JSX.Element => {
setLoading(true); setLoading(true);
try { try {
const [subscriptionResponse, invoiceResponse] = await Promise.all([ const [subscriptionResponse, invoiceResponse] = await Promise.all([
axiosInstance.get<SubscriptionMe>('/finance/subscription/'), axiosInstance.get<SubscriptionMe>('/monetization/subscription/'),
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'), axiosInstance.get<FinanceInvoice[]>('/monetization/invoices/'),
]); ]);
setSubscription(subscriptionResponse.data || null); setSubscription(subscriptionResponse.data || null);
setHasPortalAccess( setHasPortalAccess(
@@ -231,7 +231,7 @@ const UsageSummaryCard = (): JSX.Element => {
try { try {
const returnUrl = `${window.location.origin}/account/`; const returnUrl = `${window.location.origin}/account/`;
const response = await axiosInstance.post<{ portal_url: string }>( const response = await axiosInstance.post<{ portal_url: string }>(
'/finance/portal/', '/monetization/portal/',
{ return_url: returnUrl } { return_url: returnUrl }
); );
if (response.data?.portal_url) { if (response.data?.portal_url) {
@@ -3,6 +3,7 @@ import { Account, AccountType } from "../data";
import { AuthContext } from "./AuthContext"; import { AuthContext } from "./AuthContext";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { axiosInstance } from "../../axiosApi"; import { axiosInstance } from "../../axiosApi";
import { logInRevenueCat } from "../utils/revenueCat";
type AccountProviderProps ={ type AccountProviderProps ={
children? : ReactNode; children? : ReactNode;
@@ -28,6 +29,7 @@ const AccountProvider = ({children}: AccountProviderProps) => {
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/') const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/')
const account: Account = new Account({ const account: Account = new Account({
id: get_user_response.data.id,
email: get_user_response.data.email, email: get_user_response.data.email,
first_name: get_user_response.data.first_name, first_name: get_user_response.data.first_name,
last_name: get_user_response.data.last_name, last_name: get_user_response.data.last_name,
@@ -43,6 +45,9 @@ const AccountProvider = ({children}: AccountProviderProps) => {
}); });
setAccount(account); setAccount(account);
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
console.warn('RevenueCat logIn', err)
);
} }
@@ -0,0 +1,204 @@
import React, { useContext } from 'react';
import { act, render, screen } from '@testing-library/react';
import { MessageContext, MessageProvider } from './MessageContext';
import { WebSocketContext } from './WebSocketContext';
import { AccountContext } from './AccountContext';
import { ConversationContext } from './ConversationContext';
import { trackEvent } from '../utils/analytics';
jest.mock('../../axiosApi', () => ({
axiosInstance: {
get: jest.fn().mockResolvedValue({ data: [] }),
},
}));
jest.mock('../utils/analytics', () => ({
AnalyticsEvents: {
CONVERSATION_CREATED: 'Conversation Created',
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
},
trackEvent: jest.fn(),
}));
type Capture = { callback: ((message: string) => void) | null };
const buildHarness = () => {
const capture: Capture = { callback: null };
const subscribe = (_channel: string, cb: (message: string) => void) => {
capture.callback = cb;
};
const unsubscribe = jest.fn();
const Harness = ({ reconnectGeneration = 0 }: { reconnectGeneration?: number }) => (
<AccountContext.Provider
value={{ account: { email: 'test@example.com' } as never, setAccount: () => {} }}
>
<ConversationContext.Provider
value={{
conversations: [],
setConversations: () => {},
selectedConversation: 1,
setSelectedConversation: () => {},
deleteConversation: () => {},
}}
>
<WebSocketContext.Provider
value={[subscribe, unsubscribe, null, jest.fn(), true, 'CONNECTED', reconnectGeneration] as never}
>
<MessageProvider>
<Probe />
</MessageProvider>
</WebSocketContext.Provider>
</ConversationContext.Provider>
</AccountContext.Provider>
);
return { Harness, capture };
};
const Probe = () => {
const ctx = useContext(MessageContext);
return (
<div>
<span data-testid="stage">{ctx.activityStage ?? 'null'}</span>
<span data-testid="label">{ctx.activityLabel ?? 'null'}</span>
<span data-testid="detail">{ctx.activityDetail ?? 'null'}</span>
<span data-testid="history-count">{ctx.activityHistory.length}</span>
<span data-testid="stream-interrupted">{ctx.streamInterrupted ? 'yes' : 'no'}</span>
<button type="button" onClick={() => ctx.clearActivity()}>
clear
</button>
</div>
);
};
const send = (capture: Capture, message: string) => {
act(() => {
capture.callback?.(message);
});
};
const sendStatus = (
capture: Capture,
stage: string,
label: string,
detail: string | null = null,
) => {
send(
capture,
JSON.stringify({ v: 1, type: 'status', data: { stage, label, detail } }),
);
};
describe('MessageContext activity status (#96)', () => {
beforeEach(() => {
(trackEvent as jest.Mock).mockClear();
});
it('applies the first status frame as the current stage/label/detail', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web', 'query: cats');
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
expect(screen.getByTestId('label')).toHaveTextContent('Searching the web');
expect(screen.getByTestId('detail')).toHaveTextContent('query: cats');
expect(screen.getByTestId('history-count')).toHaveTextContent('0');
});
it('rolls a completed stage into history and tracks ACTIVITY_STAGE_COMPLETED (no label/detail text)', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
sendStatus(capture, 'reading', 'Reading sources');
expect(screen.getByTestId('stage')).toHaveTextContent('reading');
expect(screen.getByTestId('label')).toHaveTextContent('Reading sources');
expect(screen.getByTestId('history-count')).toHaveTextContent('1');
expect(trackEvent).toHaveBeenCalledWith(
'Activity Stage Completed',
expect.objectContaining({ stage: 'searching', durationMs: expect.any(Number) }),
);
const call = (trackEvent as jest.Mock).mock.calls.find(
([name]) => name === 'Activity Stage Completed',
);
expect(Object.keys(call![1])).toEqual(['stage', 'durationMs']);
});
it('clears the label display (but not the stage) once the writing stage starts', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
sendStatus(capture, 'writing', 'Writing the answer');
expect(screen.getByTestId('stage')).toHaveTextContent('writing');
expect(screen.getByTestId('label')).toHaveTextContent('null');
});
it('clears activity on END_OF_THE_STREAM', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
sendStatus(capture, 'writing', 'Writing the answer');
send(capture, 'END_OF_THE_STREAM_ENDER_GAME_42');
expect(screen.getByTestId('stage')).toHaveTextContent('null');
expect(screen.getByTestId('label')).toHaveTextContent('null');
expect(screen.getByTestId('detail')).toHaveTextContent('null');
expect(screen.getByTestId('history-count')).toHaveTextContent('0');
});
it('ignores malformed status frames without throwing', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
send(
capture,
JSON.stringify({ v: 1, type: 'status', data: { stage: 'searching' } }),
);
expect(screen.getByTestId('stage')).toHaveTextContent('null');
});
it('clears activity via clearActivity()', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
act(() => {
screen.getByRole('button', { name: 'clear' }).click();
});
expect(screen.getByTestId('stage')).toHaveTextContent('null');
});
it('clears activity on the streamInterrupted reconnect path', async () => {
const { Harness, capture } = buildHarness();
const { rerender } = render(<Harness reconnectGeneration={0} />);
await act(async () => {});
// Simulate an in-flight generating turn so the reconnect effect treats it as interrupted.
send(capture, 'CONVERSATION_ID');
sendStatus(capture, 'searching', 'Searching the web');
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
rerender(<Harness reconnectGeneration={1} />);
await act(async () => {});
expect(screen.getByTestId('stream-interrupted')).toHaveTextContent('yes');
expect(screen.getByTestId('stage')).toHaveTextContent('null');
});
});
+76 -6
View File
@@ -1,4 +1,4 @@
import { createContext, ReactNode, useContext, useEffect, useRef, useState } from "react"; import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { WebSocketContext } from "./WebSocketContext"; import { WebSocketContext } from "./WebSocketContext";
import { AccountContext } from "./AccountContext"; import { AccountContext } from "./AccountContext";
import { ConversationContext } from "./ConversationContext"; import { ConversationContext } from "./ConversationContext";
@@ -6,7 +6,12 @@ import { ConversationPrompt, ConversationPromptType } from "../data";
import { axiosInstance } from "../../axiosApi"; import { axiosInstance } from "../../axiosApi";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { AnalyticsEvents, trackEvent } from "../utils/analytics"; import { AnalyticsEvents, trackEvent } from "../utils/analytics";
import { parseCitationsData, parseVersionedFrame } from "../utils/wsFrames"; import {
ActivityHistoryEntry,
parseCitationsData,
parseStatusData,
parseVersionedFrame,
} from "../utils/wsFrames";
type MessageProviderProps ={ type MessageProviderProps ={
children? : ReactNode; children? : ReactNode;
@@ -21,6 +26,12 @@ type IMessageContext = {
/** True when a stream was interrupted by a socket drop; cleared on refetch/retry. */ /** True when a stream was interrupted by a socket drop; cleared on refetch/retry. */
streamInterrupted: boolean; streamInterrupted: boolean;
clearStreamInterrupted: () => void; clearStreamInterrupted: () => void;
/** Live activity status from versioned "status" WS frames (#96). */
activityStage: string | null;
activityLabel: string | null;
activityDetail: string | null;
activityHistory: ActivityHistoryEntry[];
clearActivity: () => void;
} }
const initialValues = { const initialValues = {
@@ -31,6 +42,11 @@ const initialValues = {
isGeneratingMessage: false, isGeneratingMessage: false,
streamInterrupted: false, streamInterrupted: false,
clearStreamInterrupted: () => {}, clearStreamInterrupted: () => {},
activityStage: null,
activityLabel: null,
activityDetail: null,
activityHistory: [],
clearActivity: () => {},
} }
const MessageContext = createContext<IMessageContext>(initialValues); const MessageContext = createContext<IMessageContext>(initialValues);
@@ -47,6 +63,10 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([]) const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false) const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false) const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false)
const [activityStage, setActivityStage] = useState<string | null>(null)
const [activityLabel, setActivityLabel] = useState<string | null>(null)
const [activityDetail, setActivityDetail] = useState<string | null>(null)
const [activityHistory, setActivityHistory] = useState<ActivityHistoryEntry[]>([])
const messageRef = useRef('') const messageRef = useRef('')
const messageResponsePart = useRef(0); const messageResponsePart = useRef(0);
@@ -55,9 +75,19 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
const isGeneratingRef = useRef(false) const isGeneratingRef = useRef(false)
const prevReconnectGenerationRef = useRef(0) const prevReconnectGenerationRef = useRef(0)
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** Tracks the in-progress activity stage so we can roll it into history with a duration. */
const activeStageRef = useRef<{ stage: string; label: string; startedAt: number } | null>(null)
const clearStreamInterrupted = () => setStreamInterrupted(false) const clearStreamInterrupted = () => setStreamInterrupted(false)
const clearActivity = () => {
activeStageRef.current = null
setActivityStage(null)
setActivityLabel(null)
setActivityDetail(null)
setActivityHistory([])
}
async function GetConversationDetails(conversationId: number | undefined) { async function GetConversationDetails(conversationId: number | undefined) {
if (!conversationId) { if (!conversationId) {
setConversationDetails([]) setConversationDetails([])
@@ -98,7 +128,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
} }
} }
const schedulePostStreamRefetch = () => { const schedulePostStreamRefetch = useCallback(() => {
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current) if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
const conversationId = selectedConversationRef.current const conversationId = selectedConversationRef.current
if (!conversationId) return if (!conversationId) return
@@ -107,7 +137,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
refetchTimerRef.current = setTimeout(() => { refetchTimerRef.current = setTimeout(() => {
void GetConversationDetails(conversationId) void GetConversationDetails(conversationId)
}, 800) }, 800)
} }, [])
useEffect(() => { useEffect(() => {
GetConversationDetails(selectedConversation) GetConversationDetails(selectedConversation)
@@ -142,6 +172,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
setIsGeneratingMessage(false) setIsGeneratingMessage(false)
setStateMessage('') setStateMessage('')
setStreamInterrupted(true) setStreamInterrupted(true)
clearActivity()
const details = [...conversationRef.current] const details = [...conversationRef.current]
if (details.length > 0) { if (details.length > 0) {
@@ -187,8 +218,41 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
conversationRef.current = details conversationRef.current = details
setConversationDetails(details) setConversationDetails(details)
schedulePostStreamRefetch() schedulePostStreamRefetch()
} else if (frame.type === 'status') {
const statusData = parseStatusData(frame.data)
if (statusData) {
const now = Date.now()
const previous = activeStageRef.current
if (previous && previous.stage !== statusData.stage) {
const durationMs = now - previous.startedAt
setActivityHistory((history) => [
...history,
{
stage: previous.stage,
label: previous.label,
startedAt: previous.startedAt,
finishedAt: now,
},
])
trackEvent(AnalyticsEvents.ACTIVITY_STAGE_COMPLETED, {
stage: previous.stage,
durationMs,
})
} }
// status / unknown types: no-op (compatible with #96) activeStageRef.current = {
stage: statusData.stage,
label: statusData.label,
startedAt: previous && previous.stage === statusData.stage
? previous.startedAt
: now,
}
setActivityStage(statusData.stage)
// Tokens take over the label once the model starts writing.
setActivityLabel(statusData.stage === 'writing' ? null : statusData.label)
setActivityDetail(statusData.detail ?? null)
}
}
// other unknown types: no-op
return return
} }
@@ -208,6 +272,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
isGeneratingRef.current = false isGeneratingRef.current = false
setIsGeneratingMessage(false) setIsGeneratingMessage(false)
setStreamInterrupted(false) setStreamInterrupted(false)
clearActivity()
schedulePostStreamRefetch() schedulePostStreamRefetch()
} }
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){ else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
@@ -251,7 +316,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
/* unsubscribe from channel during cleanup */ /* unsubscribe from channel during cleanup */
unsubscribe(channelName) unsubscribe(channelName)
} }
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation]) }, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation, schedulePostStreamRefetch])
return( return(
@@ -263,6 +328,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
isGeneratingMessage, isGeneratingMessage,
streamInterrupted, streamInterrupted,
clearStreamInterrupted, clearStreamInterrupted,
activityStage,
activityLabel,
activityDetail,
activityHistory,
clearActivity,
}}> }}>
{children} {children}
</MessageContext.Provider> </MessageContext.Provider>
+4
View File
@@ -255,6 +255,7 @@ export class AdminAnalytics {
} }
export interface AccountType { export interface AccountType {
id?: number;
email: string; email: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
@@ -268,9 +269,11 @@ export interface AccountType {
export interface PreferencesType { export interface PreferencesType {
order: boolean; order: boolean;
use_conversation_context?: boolean;
} }
export class Account { export class Account {
id?: number;
email: string = ''; email: string = '';
first_name: string =''; first_name: string ='';
last_name: string = ''; last_name: string = '';
@@ -282,6 +285,7 @@ export class Account {
has_signed_tos: boolean = false; has_signed_tos: boolean = false;
constructor(initializer?: any){ constructor(initializer?: any){
if (!initializer) return; if (!initializer) return;
if (initializer.id != null) this.id = Number(initializer.id);
if (initializer.email) this.email = initializer.email; if (initializer.email) this.email = initializer.email;
if (initializer.first_name) this.first_name = initializer.first_name; if (initializer.first_name) this.first_name = initializer.first_name;
if (initializer.is_company_manager) this.is_company_manager = initializer.is_company_manager; if (initializer.is_company_manager) this.is_company_manager = initializer.is_company_manager;
+3 -3
View File
@@ -6,7 +6,7 @@ import { planAllowsRag, SubscriptionMe } from '../utils/finance';
/** /**
* Module-level cache so every component that calls useSubscription() during the * Module-level cache so every component that calls useSubscription() during the
* same session shares one /finance/subscription/ request instead of each * same session shares one /monetization/subscription/ request instead of each
* mounting its own (e.g. Header2 + DocumentStoragePage on the same page). * mounting its own (e.g. Header2 + DocumentStoragePage on the same page).
*/ */
let cachedSubscription: SubscriptionMe | null = null; let cachedSubscription: SubscriptionMe | null = null;
@@ -18,7 +18,7 @@ async function loadSubscription(): Promise<SubscriptionMe | null> {
} }
if (!inFlightRequest) { if (!inFlightRequest) {
inFlightRequest = axiosInstance inFlightRequest = axiosInstance
.get<SubscriptionMe>('/finance/subscription/') .get<SubscriptionMe>('/monetization/subscription/')
.then((response: AxiosResponse<SubscriptionMe>) => { .then((response: AxiosResponse<SubscriptionMe>) => {
cachedSubscription = response.data || null; cachedSubscription = response.data || null;
return cachedSubscription; return cachedSubscription;
@@ -44,7 +44,7 @@ export type UseSubscriptionResult = {
refresh: () => Promise<void>; refresh: () => Promise<void>;
}; };
/** Fetches /finance/subscription/ once per session (shared across callers) and exposes plan-derived flags. */ /** Fetches /monetization/subscription/ once per session (shared across callers) and exposes plan-derived flags. */
export function useSubscription(): UseSubscriptionResult { export function useSubscription(): UseSubscriptionResult {
const { authenticated } = useContext(AuthContext); const { authenticated } = useContext(AuthContext);
const [subscription, setSubscription] = useState<SubscriptionMe | null>(cachedSubscription); const [subscription, setSubscription] = useState<SubscriptionMe | null>(cachedSubscription);
@@ -0,0 +1,35 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import AccountDeletionPage from './AccountDeletionPage';
describe('AccountDeletionPage', () => {
it('renders Hesychia deletion steps and retention copy without auth', () => {
render(
<MemoryRouter>
<AccountDeletionPage />
</MemoryRouter>,
);
expect(
screen.getByRole('heading', { name: /Hesychia — Request account deletion/i }),
).toBeInTheDocument();
expect(screen.getByText(/ai\.hesychia\.chat/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /How to delete your account/i }),
).toBeInTheDocument();
expect(screen.getByText(/Delete my account/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Deactivated immediately/i }),
).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Data retained after soft-delete/i }),
).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Retention period/i })).toBeInTheDocument();
expect(screen.getByText(/no automatic hard-purge schedule/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Cannot access your account/i }),
).toBeInTheDocument();
expect(screen.getByText(/30 days/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,130 @@
import React from 'react';
import { Box, Button, Card, CardContent, Divider, Stack, Typography } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
/**
* Public Play / App Store account-deletion instructions (#103).
* No auth required — reviewers and signed-out users must be able to read this page.
*/
const AccountDeletionPage = (): JSX.Element => (
<Box sx={{ py: 4, px: 2, minHeight: '100vh', bgcolor: 'background.default' }}>
<Card sx={{ maxWidth: 900, mx: 'auto', width: '100%' }}>
<CardContent>
<Typography variant="h3" component="h1" gutterBottom>
Hesychia Request account deletion
</Typography>
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
Hesychia (ai.hesychia.chat) · hesychia.ai · AI ML Operations, LLC
</Typography>
<Typography variant="body1" color="text.secondary">
Use the steps below to request deletion of your Hesychia account. This page does not
require sign-in to read.
</Typography>
</CardContent>
<Divider />
<CardContent>
<Stack spacing={3}>
<Box>
<Typography variant="h5" component="h2" gutterBottom>
How to delete your account
</Typography>
<Typography variant="body1" component="ol" sx={{ pl: 2.5, m: 0 }}>
<li>
Open <strong>Hesychia</strong> in the mobile app or at{' '}
<a href="https://hesychia.ai" rel="noopener noreferrer">
hesychia.ai
</a>
.
</li>
<li>Sign in with the account you want deleted.</li>
<li>
Go to <strong>Account</strong>.
</li>
<li>
Choose <strong>Delete my account</strong>, then confirm by typing your account
email.
</li>
</Typography>
</Box>
<Box>
<Typography variant="h5" component="h2" gutterBottom>
What happens when you delete
</Typography>
<Typography variant="h6" component="h3" gutterBottom sx={{ mt: 2 }}>
Deactivated immediately
</Typography>
<Typography variant="body1" component="ul" sx={{ pl: 2.5, m: 0 }}>
<li>Your account is marked deleted and set inactive.</li>
<li>You lose access to Hesychia and cannot sign in again with that account.</li>
<li>Your conversations are hidden from the product.</li>
<li>Active sessions are invalidated (refresh tokens are blacklisted).</li>
</Typography>
<Typography variant="h6" component="h3" gutterBottom sx={{ mt: 2 }}>
Data retained after soft-delete
</Typography>
<Typography variant="body1" paragraph>
Hesychia currently uses a <strong>soft-delete</strong> process. After you delete your
account, personal data associated with the account (including profile details,
conversation content, uploaded documents / RAG vectors, and authentication event
records) may remain in our systems for administration, security, and audit purposes.
This is not a full erasure of all stored data.
</Typography>
<Typography variant="h6" component="h3" gutterBottom>
Retention period
</Typography>
<Typography variant="body1" paragraph sx={{ mb: 0 }}>
There is currently <strong>no automatic hard-purge schedule</strong>. Soft-deleted
data is retained until a future purge process is implemented or until we process a
separate retention / erasure request through the contact path below. We will not claim
complete erasure while soft-delete-only remains in effect.
</Typography>
</Box>
<Box>
<Typography variant="h5" component="h2" gutterBottom>
Cannot access your account?
</Typography>
<Typography variant="body1" paragraph>
If you cannot sign in to complete in-app deletion, contact AI ML Operations, LLC via{' '}
<a href="https://www.aimloperations.com" rel="noopener noreferrer">
aimloperations.com
</a>{' '}
and include the email address used for your Hesychia account. We aim to respond to
account-deletion requests within <strong>30 days</strong>.
</Typography>
<Typography variant="body2" color="text.secondary">
Staff accounts cannot self-delete in the app; use the contact path above.
</Typography>
</Box>
</Stack>
</CardContent>
<Divider />
<CardContent>
<Typography variant="body2" color="text.secondary">
Already have an account?{' '}
<Button component={RouterLink} to="/signin/" size="small">
Sign in
</Button>{' '}
·{' '}
<Button component={RouterLink} to="/account/" size="small">
Account
</Button>{' '}
·{' '}
<Button component={RouterLink} to="/terms_of_service/" size="small">
Terms of Service
</Button>
</Typography>
</CardContent>
</Card>
</Box>
);
export default AccountDeletionPage;
@@ -252,32 +252,6 @@ const IconButton = styled.button`
} }
`; `;
const StyledSelect = styled.select`
background: transparent;
border: none;
flex-shrink: 0;
max-width: 7rem;
color: ${({ theme }) => theme.colors.text};
font-size: 0.9rem;
padding: 0.5rem;
outline: none;
cursor: pointer;
margin-right: 0.5rem;
border-right: 1px solid ${({ theme }) => theme.colors.cardBorder};
@media (max-width: 768px) {
max-width: 5.5rem;
font-size: 0.8rem;
padding: 0.4rem 0.2rem;
margin-right: 0.25rem;
}
option {
background: ${({ theme }) => theme.colors.background || '#1a1a1a'};
color: ${({ theme }) => theme.colors.text};
}
`;
const ConversationItem = styled.div<{ $active: boolean }>` const ConversationItem = styled.div<{ $active: boolean }>`
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@@ -343,11 +317,13 @@ const validationSchema = Yup.object().shape({
.required("This is required"), .required("This is required"),
}); });
/** Fixed until product re-exposes a model picker (#106). */
const DEFAULT_MODEL_NAME = "THINKING";
type PromptValues = { type PromptValues = {
prompt: string; prompt: string;
file: Blob | null; file: Blob | null;
fileType: string | null; fileType: string | null;
modelName: string;
}; };
const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.Element => { const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.Element => {
@@ -370,6 +346,11 @@ const AsyncDashboardInner = (): JSX.Element => {
setConversationDetails, setConversationDetails,
stateMessage, stateMessage,
streamInterrupted, streamInterrupted,
activityStage,
activityLabel,
activityDetail,
activityHistory,
clearActivity,
} = useContext(MessageContext); } = useContext(MessageContext);
const conversationRef = useRef(conversationDetails); const conversationRef = useRef(conversationDetails);
@@ -442,7 +423,7 @@ const AsyncDashboardInner = (): JSX.Element => {
})(); })();
const handlePromptSubmit = async ( const handlePromptSubmit = async (
{ prompt, file, fileType, modelName }: PromptValues, { prompt, file, fileType }: PromptValues,
{ resetForm }: any, { resetForm }: any,
): Promise<void> => { ): Promise<void> => {
const trimmedPrompt = (prompt ?? "").trim(); const trimmedPrompt = (prompt ?? "").trim();
@@ -450,6 +431,7 @@ const AsyncDashboardInner = (): JSX.Element => {
return; return;
} }
try { try {
clearActivity();
const tempConversations: ConversationPrompt[] = [ const tempConversations: ConversationPrompt[] = [
...conversationDetails, ...conversationDetails,
new ConversationPrompt({ message: trimmedPrompt, user_created: true }), new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
@@ -458,7 +440,7 @@ const AsyncDashboardInner = (): JSX.Element => {
conversationRef.current = tempConversations; conversationRef.current = tempConversations;
setConversationDetails(tempConversations); setConversationDetails(tempConversations);
sendMessage(trimmedPrompt, selectedConversation, file, fileType, modelName); sendMessage(trimmedPrompt, selectedConversation, file, fileType, DEFAULT_MODEL_NAME);
trackEvent(AnalyticsEvents.MESSAGE_SENT, { trackEvent(AnalyticsEvents.MESSAGE_SENT, {
hasConversation: Boolean(selectedConversation), hasConversation: Boolean(selectedConversation),
hasAttachment: Boolean(file), hasAttachment: Boolean(file),
@@ -468,7 +450,6 @@ const AsyncDashboardInner = (): JSX.Element => {
prompt: "", prompt: "",
file: null, file: null,
fileType: null, fileType: null,
modelName: modelName, // Keep the selected model
} }
}); });
@@ -569,7 +550,6 @@ const AsyncDashboardInner = (): JSX.Element => {
{( {(
[ [
['pdf', 'PDF'], ['pdf', 'PDF'],
['docx', 'Word (.docx)'],
['csv', 'CSV'], ['csv', 'CSV'],
['xlsx', 'Excel (.xlsx)'], ['xlsx', 'Excel (.xlsx)'],
['txt', 'Plain text'], ['txt', 'Plain text'],
@@ -606,6 +586,11 @@ const AsyncDashboardInner = (): JSX.Element => {
onRatingChange={(rating) => onRatingChange={(rating) =>
updatePromptRating(convo_detail.id, rating) updatePromptRating(convo_detail.id, rating)
} }
activityStage={isLiveStream ? activityStage : null}
activityLabel={isLiveStream ? activityLabel : null}
activityDetail={isLiveStream ? activityDetail : null}
activityHistory={isLiveStream ? activityHistory : []}
activityInterrupted={isLiveStream && streamInterrupted}
/> />
); );
}) })
@@ -630,7 +615,6 @@ const AsyncDashboardInner = (): JSX.Element => {
prompt: "", prompt: "",
file: null, file: null,
fileType: null, fileType: null,
modelName: "FAST",
}} }}
validationSchema={validationSchema} validationSchema={validationSchema}
onSubmit={handlePromptSubmit} onSubmit={handlePromptSubmit}
@@ -681,14 +665,6 @@ const AsyncDashboardInner = (): JSX.Element => {
}} }}
/> />
<Field
as={StyledSelect}
name="modelName"
>
<option value="FAST">FAST</option>
<option value="THINKING">THINKING</option>
</Field>
<Field name="prompt"> <Field name="prompt">
{({ field }: any) => ( {({ field }: any) => (
<StyledInput <StyledInput
@@ -7,6 +7,11 @@ import { AccountContext } from '../../contexts/AccountContext';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null); jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('../../utils/revenueCat', () => ({
logInRevenueCat: () => Promise.resolve(),
purchasePlan: () => Promise.resolve(),
}));
const mockPost = jest.fn(); const mockPost = jest.fn();
const mockGet = jest.fn(); const mockGet = jest.fn();
const mockApplyAccessToken = jest.fn(); const mockApplyAccessToken = jest.fn();
@@ -128,7 +133,7 @@ describe('AuthCallback', () => {
await waitFor(() => { await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith( expect(mockPost).toHaveBeenCalledWith(
'/finance/checkout/', '/monetization/checkout/',
expect.objectContaining({ expect.objectContaining({
success_url: expect.stringContaining('/billing/success'), success_url: expect.stringContaining('/billing/success'),
cancel_url: expect.stringContaining('/billing/cancel'), cancel_url: expect.stringContaining('/billing/cancel'),
@@ -10,6 +10,9 @@ import { AccountContext } from '../../contexts/AccountContext';
import { Account, AccountType } from '../../data'; import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics'; import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import { checkoutReturnUrls } from '../../utils/finance';
import { isNativePlatform } from '../../platform/nativePlatform';
import { logInRevenueCat, purchasePlan } from '../../utils/revenueCat';
const PageContainer = styled.div` const PageContainer = styled.div`
position: relative; position: relative;
@@ -81,14 +84,6 @@ const NavLink = styled(Link)`
} }
`; `;
function checkoutReturnUrls(): { success_url: string; cancel_url: string } {
const origin = window.location.origin;
return {
success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/billing/cancel`,
};
}
const AuthCallback = (): JSX.Element => { const AuthCallback = (): JSX.Element => {
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext); const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
const { setAccount } = useContext(AccountContext); const { setAccount } = useContext(AccountContext);
@@ -142,6 +137,7 @@ const AuthCallback = (): JSX.Element => {
} }
const account = new Account({ const account = new Account({
id: get_user_response.data.id,
email: get_user_response.data.email, email: get_user_response.data.email,
first_name: get_user_response.data.first_name, first_name: get_user_response.data.first_name,
last_name: get_user_response.data.last_name, last_name: get_user_response.data.last_name,
@@ -165,12 +161,27 @@ const AuthCallback = (): JSX.Element => {
trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'sso' }); trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'sso' });
} }
identifyAccount(account); identifyAccount(account);
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
console.warn('RevenueCat logIn', err)
);
if (needsCheckout) { if (needsCheckout) {
if (isNativePlatform()) {
setStatusText('Starting store purchase…');
try {
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup_native' });
await purchasePlan();
} catch (purchaseError) {
console.warn('RevenueCat purchase after SSO signup', purchaseError);
}
navigate('/account/', { replace: true });
return;
}
setStatusText('Starting checkout…'); setStatusText('Starting checkout…');
const { success_url, cancel_url } = checkoutReturnUrls(); const { success_url, cancel_url } = checkoutReturnUrls();
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup' }); trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup' });
const checkoutResponse = await axiosInstance.post('/finance/checkout/', { const checkoutResponse = await axiosInstance.post('/monetization/checkout/', {
success_url, success_url,
cancel_url, cancel_url,
}); });
@@ -60,7 +60,7 @@ const subscriptionResponse = (rag: boolean) => ({
const mockGetByUrl = (overrides: { rag: boolean }) => { const mockGetByUrl = (overrides: { rag: boolean }) => {
mockGet.mockImplementation((url: string) => { mockGet.mockImplementation((url: string) => {
if (url === '/finance/subscription/') { if (url === '/monetization/subscription/') {
return Promise.resolve(subscriptionResponse(overrides.rag)); return Promise.resolve(subscriptionResponse(overrides.rag));
} }
if (typeof url === 'string' && url.startsWith('/documents/')) { if (typeof url === 'string' && url.startsWith('/documents/')) {
@@ -8,6 +8,10 @@ import { AccountContext } from '../../contexts/AccountContext';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null); jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('../../utils/revenueCat', () => ({
logInRevenueCat: () => Promise.resolve(),
}));
const mockPost = jest.fn(); const mockPost = jest.fn();
const mockGet = jest.fn(); const mockGet = jest.fn();
@@ -10,6 +10,7 @@ import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons'; import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics'; import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import { logInRevenueCat } from '../../utils/revenueCat';
import styled from 'styled-components'; import styled from 'styled-components';
import * as Yup from 'yup'; import * as Yup from 'yup';
import hesychiaMark from '../../assets/brand/hesychia-mark.png'; import hesychiaMark from '../../assets/brand/hesychia-mark.png';
@@ -221,6 +222,7 @@ const SignIn = (): JSX.Element => {
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/') const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/')
const account = new Account({ const account = new Account({
id: get_user_response.data.id,
email: get_user_response.data.email, email: get_user_response.data.email,
first_name: get_user_response.data.first_name, first_name: get_user_response.data.first_name,
last_name: get_user_response.data.last_name, last_name: get_user_response.data.last_name,
@@ -240,6 +242,9 @@ const SignIn = (): JSX.Element => {
setNeedsNewPassword(get_user_response.data.has_usable_password) setNeedsNewPassword(get_user_response.data.has_usable_password)
trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'password' }); trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'password' });
identifyAccount(account); identifyAccount(account);
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
console.warn('RevenueCat logIn', err)
);
if (account.has_signed_tos) { if (account.has_signed_tos) {
navigate('/'); navigate('/');
} else { } else {
@@ -8,6 +8,11 @@ import { AccountContext } from '../../contexts/AccountContext';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null); jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('../../utils/revenueCat', () => ({
logInRevenueCat: () => Promise.resolve(),
purchasePlan: () => Promise.resolve(),
}));
const mockPost = jest.fn(); const mockPost = jest.fn();
const mockGet = jest.fn(); const mockGet = jest.fn();
const assignMock = jest.fn(); const assignMock = jest.fn();
@@ -134,7 +139,7 @@ describe('SignUp', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/finance/checkout/', { expect(mockPost).toHaveBeenCalledWith('/monetization/checkout/', {
success_url: 'http://localhost/billing/success?session_id={CHECKOUT_SESSION_ID}', success_url: 'http://localhost/billing/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost/billing/cancel', cancel_url: 'http://localhost/billing/cancel',
}); });
+23 -10
View File
@@ -10,6 +10,9 @@ import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons'; import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics'; import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import { checkoutReturnUrls } from '../../utils/finance';
import { isNativePlatform } from '../../platform/nativePlatform';
import { logInRevenueCat, purchasePlan } from '../../utils/revenueCat';
import styled from 'styled-components'; import styled from 'styled-components';
import * as Yup from 'yup'; import * as Yup from 'yup';
import hesychiaMark from '../../assets/brand/hesychia-mark.png'; import hesychiaMark from '../../assets/brand/hesychia-mark.png';
@@ -170,14 +173,6 @@ const validationSchema = Yup.object().shape({
company_name: Yup.string(), company_name: Yup.string(),
}); });
function checkoutReturnUrls(): { success_url: string; cancel_url: string } {
const origin = window.location.origin;
return {
success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/billing/cancel`,
};
}
const SignUp = (): JSX.Element => { const SignUp = (): JSX.Element => {
const navigate = useNavigate(); const navigate = useNavigate();
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext); const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
@@ -213,6 +208,7 @@ const SignUp = (): JSX.Element => {
const loadAccount = async (): Promise<Account> => { const loadAccount = async (): Promise<Account> => {
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/'); const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/');
const account = new Account({ const account = new Account({
id: get_user_response.data.id,
email: get_user_response.data.email, email: get_user_response.data.email,
first_name: get_user_response.data.first_name, first_name: get_user_response.data.first_name,
last_name: get_user_response.data.last_name, last_name: get_user_response.data.last_name,
@@ -249,16 +245,33 @@ const SignUp = (): JSX.Element => {
const account = await loadAccount(); const account = await loadAccount();
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' }); trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' });
identifyAccount(account); identifyAccount(account);
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
console.warn('RevenueCat logIn', err)
);
const { success_url, cancel_url } = checkoutReturnUrls();
const needsCheckout = registerResponse.data?.needs_checkout !== false; const needsCheckout = registerResponse.data?.needs_checkout !== false;
if (!needsCheckout) { if (!needsCheckout) {
navigate('/'); navigate('/');
return; return;
} }
// Native: store IAP via RevenueCat (BillingSection) — skip Stripe Checkout.
if (isNativePlatform()) {
try {
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup_native' });
await purchasePlan();
navigate('/account/');
return;
} catch (purchaseError: unknown) {
console.warn('RevenueCat purchase after signup', purchaseError);
navigate('/account/');
return;
}
}
const { success_url, cancel_url } = checkoutReturnUrls();
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' }); trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' });
const checkoutResponse = await axiosInstance.post('/finance/checkout/', { const checkoutResponse = await axiosInstance.post('/monetization/checkout/', {
success_url, success_url,
cancel_url, cancel_url,
}); });
+2
View File
@@ -26,6 +26,7 @@ const queue: QueuedCall[] = [];
* | Subscription Cancel Started | Cancel intent (#75) | * | Subscription Cancel Started | Cancel intent (#75) |
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) | * | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) | * | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
* | Activity Stage Completed | Live activity status stage rolled over (#96) |
*/ */
export const AnalyticsEvents = { export const AnalyticsEvents = {
LOGIN_SUCCESS: 'Login Success', LOGIN_SUCCESS: 'Login Success',
@@ -51,6 +52,7 @@ export const AnalyticsEvents = {
MESSAGE_RATED: 'Message Rated', MESSAGE_RATED: 'Message Rated',
MESSAGE_RATING_REASON: 'Message Rating Reason', MESSAGE_RATING_REASON: 'Message Rating Reason',
MESSAGE_EXPORTED: 'Message Exported', MESSAGE_EXPORTED: 'Message Exported',
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
} as const; } as const;
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents]; export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
+2 -168
View File
@@ -8,7 +8,7 @@ import {
type MdBlock, type MdBlock,
} from './markdownUtils'; } from './markdownUtils';
export type ExportFormat = 'pdf' | 'docx' | 'csv' | 'xlsx' | 'txt'; export type ExportFormat = 'pdf' | 'csv' | 'xlsx' | 'txt';
export type ExportTurn = { export type ExportTurn = {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
@@ -52,7 +52,7 @@ function citationFootnotes(citations?: Citation[]): string {
export async function exportChat(options: ExportOptions): Promise<void> { export async function exportChat(options: ExportOptions): Promise<void> {
const { format, scope, title, turns } = options; const { format, scope, title, turns } = options;
const filename = buildExportFilename(title, format === 'docx' ? 'docx' : format); const filename = buildExportFilename(title, format);
const exportedAt = new Date().toISOString(); const exportedAt = new Date().toISOString();
// Yield so large conversations don't freeze the main thread (#97). // Yield so large conversations don't freeze the main thread (#97).
@@ -71,9 +71,6 @@ export async function exportChat(options: ExportOptions): Promise<void> {
case 'pdf': case 'pdf':
await exportPdf(title, turns, exportedAt, filename); await exportPdf(title, turns, exportedAt, filename);
break; break;
case 'docx':
await exportDocx(title, turns, exportedAt, filename);
break;
default: default:
throw new Error(`Unsupported export format: ${format}`); throw new Error(`Unsupported export format: ${format}`);
} }
@@ -304,169 +301,6 @@ async function exportPdf(
}); });
} }
async function exportDocx(
title: string,
turns: ExportTurn[],
exportedAt: string,
filename: string,
): Promise<void> {
const {
Document,
Packer,
Paragraph,
TextRun,
HeadingLevel,
Table,
TableRow,
TableCell,
WidthType,
Header,
} = await import('docx');
const children: InstanceType<typeof Paragraph | typeof Table>[] = [
new Paragraph({
text: title,
heading: HeadingLevel.TITLE,
}),
new Paragraph({
children: [
new TextRun({ text: `Exported ${exportedAt}`, italics: true, size: 18, color: '666666' }),
],
}),
];
for (let i = 0; i < turns.length; i += 1) {
if (i > 0 && i % 20 === 0) await yieldToUi();
const turn = turns[i];
if (turns.length > 1) {
children.push(
new Paragraph({
children: [
new TextRun({
text: `${roleLabel(turn.role)}${turn.timestamp ? ` · ${formatTs(turn.timestamp)}` : ''}`,
bold: true,
}),
],
spacing: { before: 240 },
}),
);
}
for (const block of parseMarkdownBlocks(turn.message)) {
switch (block.type) {
case 'heading': {
const level =
block.level === 1
? HeadingLevel.HEADING_1
: block.level === 2
? HeadingLevel.HEADING_2
: HeadingLevel.HEADING_3;
children.push(new Paragraph({ text: block.text, heading: level }));
break;
}
case 'paragraph':
children.push(new Paragraph({ text: block.text }));
break;
case 'code':
children.push(
new Paragraph({
children: [new TextRun({ text: block.text, font: 'Courier New', size: 18 })],
}),
);
break;
case 'list':
block.items.forEach((item, idx) => {
children.push(
new Paragraph({
text: block.ordered ? `${idx + 1}. ${item}` : `${item}`,
}),
);
});
break;
case 'table':
children.push(
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
new TableRow({
children: block.headers.map(
(h) =>
new TableCell({
children: [
new Paragraph({
children: [new TextRun({ text: h, bold: true })],
}),
],
}),
),
}),
...block.rows.map(
(row) =>
new TableRow({
children: block.headers.map(
(_, col) =>
new TableCell({
children: [new Paragraph({ text: row[col] ?? '' })],
}),
),
}),
),
],
}),
);
break;
default:
break;
}
}
if (turn.citations?.length) {
children.push(
new Paragraph({
text: 'Sources',
heading: HeadingLevel.HEADING_3,
}),
);
turn.citations
.slice()
.sort((a, b) => a.index - b.index)
.forEach((c) => {
children.push(
new Paragraph({
text: `[${c.index}] ${c.title}${c.url ? `${c.url}` : ''}`,
}),
);
});
}
}
const doc = new Document({
sections: [
{
headers: {
default: new Header({
children: [
new Paragraph({
children: [
new TextRun({
text: `${title} · ${exportedAt}`,
size: 16,
color: '888888',
}),
],
}),
],
}),
},
children,
},
],
});
const blob = await Packer.toBlob(doc);
downloadBlob(blob, filename);
}
/** Decide CSV/XLSX matrix strategy — exported for unit tests. */ /** Decide CSV/XLSX matrix strategy — exported for unit tests. */
export function selectTabularStrategy( export function selectTabularStrategy(
scope: ExportScope, scope: ExportScope,
+40 -1
View File
@@ -3,6 +3,10 @@ import {
formatMoneyCents, formatMoneyCents,
formatTokenCount, formatTokenCount,
humanizeStatus, humanizeStatus,
invoiceProviderLabel,
isComplimentarySubscription,
isStoreSubscription,
isStripeSubscription,
pickPrimaryInvoice, pickPrimaryInvoice,
planAllowsRag, planAllowsRag,
} from './finance'; } from './finance';
@@ -92,9 +96,44 @@ describe('finance helpers', () => {
expect(pickPrimaryInvoice(invoices)?.id).toBe(2); expect(pickPrimaryInvoice(invoices)?.id).toBe(2);
}); });
it('detects portal access from paid or subscribed invoices', () => { it('detects portal access from paid Stripe invoices only', () => {
expect(canOpenBillingPortal([baseInvoice({ status: 'open' })])).toBe(false); expect(canOpenBillingPortal([baseInvoice({ status: 'open' })])).toBe(false);
expect(canOpenBillingPortal([baseInvoice({ status: 'paid' })])).toBe(true); expect(canOpenBillingPortal([baseInvoice({ status: 'paid' })])).toBe(true);
expect(
canOpenBillingPortal([
baseInvoice({
status: 'paid',
provider: 'revenuecat',
stripe_subscription_id: null,
revenuecat_store: 'PLAY_STORE',
}),
])
).toBe(false);
});
it('labels invoice providers for history', () => {
expect(invoiceProviderLabel(baseInvoice({ provider: 'stripe' }))).toBe('Stripe');
expect(
invoiceProviderLabel(
baseInvoice({ provider: 'revenuecat', revenuecat_store: 'PLAY_STORE' })
)
).toBe('Play Store');
expect(
invoiceProviderLabel(
baseInvoice({ provider: 'revenuecat', revenuecat_store: 'APP_STORE' })
)
).toBe('App Store');
});
it('classifies subscription sources', () => {
expect(isStoreSubscription('revenuecat')).toBe(true);
expect(isStripeSubscription('stripe')).toBe(true);
expect(
isComplimentarySubscription(
{ source: 'revenuecat', needs_checkout: false },
false
)
).toBe(false);
}); });
}); });
+64 -2
View File
@@ -10,6 +10,8 @@ export type FinanceInvoice = {
stripe_invoice_id: string | null; stripe_invoice_id: string | null;
stripe_checkout_session_id: string | null; stripe_checkout_session_id: string | null;
stripe_subscription_id: string | null; stripe_subscription_id: string | null;
revenuecat_event_id?: string | null;
revenuecat_store?: string | null;
hosted_invoice_url: string; hosted_invoice_url: string;
description: string; description: string;
created: string; created: string;
@@ -25,6 +27,7 @@ export type FinancePayment = {
amount: number; amount: number;
stripe_payment_intent_id: string | null; stripe_payment_intent_id: string | null;
stripe_charge_id: string | null; stripe_charge_id: string | null;
revenuecat_transaction_id?: string | null;
paid_at: string | null; paid_at: string | null;
failure_message: string; failure_message: string;
created: string; created: string;
@@ -75,12 +78,61 @@ export type SubscriptionMe = {
source: string; source: string;
needs_checkout: boolean; needs_checkout: boolean;
stripe_subscription_id: string; stripe_subscription_id: string;
revenuecat_original_transaction_id?: string;
cancel_at_period_end?: boolean; cancel_at_period_end?: boolean;
current_period_end?: string | null; current_period_end?: string | null;
usage: SubscriptionUsage; usage: SubscriptionUsage;
}; };
export type SubscriptionSource = 'none' | 'stripe' | 'backer' | 'admin' | string; export type SubscriptionSource =
| 'none'
| 'stripe'
| 'revenuecat'
| 'backer'
| 'admin'
| string;
export function isStoreSubscription(
source: string | null | undefined
): boolean {
return source === 'revenuecat';
}
export function isStripeSubscription(
source: string | null | undefined
): boolean {
return source === 'stripe';
}
/** Human label for invoice provider / store (history badge). */
export function invoiceProviderLabel(
invoice: Pick<FinanceInvoice, 'provider' | 'revenuecat_store'>
): string {
const provider = (invoice.provider || '').toLowerCase();
if (provider === 'revenuecat') {
const store = (invoice.revenuecat_store || '').toUpperCase();
if (
store.includes('PLAY') ||
store === 'GOOGLE' ||
store === 'GOOGLE_PLAY' ||
store === 'PLAY_STORE'
) {
return 'Play Store';
}
if (
store.includes('APP_STORE') ||
store.includes('MAC') ||
store === 'APPLE' ||
store === 'APP_STORE'
) {
return 'App Store';
}
if (store) return store;
return 'Store';
}
if (provider === 'stripe') return 'Stripe';
return invoice.provider || '—';
}
/** Complimentary / admin-granted access — no Stripe cancel/change. */ /** Complimentary / admin-granted access — no Stripe cancel/change. */
export function isComplimentarySubscription( export function isComplimentarySubscription(
@@ -89,6 +141,13 @@ export function isComplimentarySubscription(
): boolean { ): boolean {
if (!subscription) return false; if (!subscription) return false;
if (subscription.source === 'backer' || subscription.source === 'admin') return true; if (subscription.source === 'backer' || subscription.source === 'admin') return true;
// Paid Stripe / store entitlements are not complimentary even without portal rows.
if (
isStripeSubscription(subscription.source) ||
isStoreSubscription(subscription.source)
) {
return false;
}
return !hasPortalAccess && subscription.needs_checkout === false; return !hasPortalAccess && subscription.needs_checkout === false;
} }
@@ -171,9 +230,12 @@ export function pickPrimaryInvoice(invoices: FinanceInvoice[]): FinanceInvoice |
return invoices[0]; return invoices[0];
} }
/** Stripe customer portal only — ignore RevenueCat / store invoices. */
export function canOpenBillingPortal(invoices: FinanceInvoice[]): boolean { export function canOpenBillingPortal(invoices: FinanceInvoice[]): boolean {
return invoices.some( return invoices.some(
(inv) => Boolean(inv.stripe_subscription_id) || inv.status === 'paid' (inv) =>
(inv.provider || 'stripe').toLowerCase() === 'stripe' &&
(Boolean(inv.stripe_subscription_id) || inv.status === 'paid')
); );
} }
@@ -0,0 +1,49 @@
jest.mock('@revenuecat/purchases-capacitor', () => ({
Purchases: {
configure: jest.fn(),
logIn: jest.fn(),
logOut: jest.fn(),
getOfferings: jest.fn(),
purchasePackage: jest.fn(),
restorePurchases: jest.fn(),
},
}));
import {
resolveAppUserId,
isPurchaseCancelledError,
} from './revenueCat';
import { ACCESS_TOKEN_KEY } from '../auth/tokenStorage';
function makeJwt(payload: Record<string, unknown>): string {
const body = btoa(JSON.stringify(payload))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `hdr.${body}.sig`;
}
describe('revenueCat helpers', () => {
beforeEach(() => {
localStorage.clear();
});
it('prefers account.id for appUserID', () => {
expect(resolveAppUserId({ id: 42 })).toBe('42');
});
it('falls back to JWT user_id claim', () => {
localStorage.setItem(ACCESS_TOKEN_KEY, makeJwt({ user_id: 99, exp: 9999999999 }));
expect(resolveAppUserId(null)).toBe('99');
});
it('returns null when no id available', () => {
expect(resolveAppUserId(undefined)).toBeNull();
});
it('detects cancelled purchase errors', () => {
expect(isPurchaseCancelledError({ userCancelled: true })).toBe(true);
expect(isPurchaseCancelledError({ code: 1 })).toBe(true);
expect(isPurchaseCancelledError({ message: 'boom' })).toBe(false);
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* RevenueCat Capacitor IAP wrapper (#100).
* No-ops on web; only configures / purchases on native platforms.
*/
import { Purchases } from '@revenuecat/purchases-capacitor';
import type { PurchasesPackage } from '@revenuecat/purchases-capacitor';
import { decodeJwtPayload } from '../auth/jwtHelpers';
import { getAccessToken } from '../auth/tokenStorage';
import { isNativePlatform } from '../platform/nativePlatform';
let configurePromise: Promise<void> | null = null;
let configured = false;
type AccountLike = { id?: number | string | null };
function getCapacitorPlatform(): string {
if (typeof window === 'undefined') return 'web';
const Cap = window.Capacitor as
| { getPlatform?: () => string; isNativePlatform?: () => boolean }
| undefined;
if (typeof Cap?.getPlatform === 'function') {
return Cap.getPlatform();
}
return 'web';
}
function revenueCatApiKey(): string {
const platform = getCapacitorPlatform();
if (platform === 'ios') {
return (process.env.REACT_APP_REVENUECAT_APPLE_API_KEY || '').trim();
}
if (platform === 'android') {
return (process.env.REACT_APP_REVENUECAT_GOOGLE_API_KEY || '').trim();
}
// Fallback: prefer Google then Apple if platform unknown on native.
return (
(process.env.REACT_APP_REVENUECAT_GOOGLE_API_KEY || '').trim() ||
(process.env.REACT_APP_REVENUECAT_APPLE_API_KEY || '').trim()
);
}
/** Prefer numeric user pk; fall back to JWT `user_id` claim. */
export function resolveAppUserId(account?: AccountLike | null): string | null {
if (account?.id != null && String(account.id).trim() !== '') {
return String(account.id);
}
const token = getAccessToken();
if (!token) return null;
const payload = decodeJwtPayload(token) as { user_id?: number | string } | null;
if (payload?.user_id != null && String(payload.user_id).trim() !== '') {
return String(payload.user_id);
}
return null;
}
export async function configureRevenueCat(): Promise<boolean> {
if (!isNativePlatform()) return false;
if (configured) return true;
if (configurePromise) {
await configurePromise;
return configured;
}
configurePromise = (async () => {
const apiKey = revenueCatApiKey();
if (!apiKey) {
console.warn(
'RevenueCat API key missing (REACT_APP_REVENUECAT_APPLE_API_KEY / GOOGLE).'
);
return;
}
await Purchases.configure({ apiKey });
configured = true;
})();
try {
await configurePromise;
} finally {
configurePromise = null;
}
return configured;
}
export async function logInRevenueCat(
appUserIdOrAccount?: string | AccountLike | null
): Promise<void> {
if (!isNativePlatform()) return;
const appUserID =
typeof appUserIdOrAccount === 'string'
? appUserIdOrAccount
: resolveAppUserId(appUserIdOrAccount);
if (!appUserID) {
console.warn('RevenueCat logIn skipped: no app user id');
return;
}
const ready = await configureRevenueCat();
if (!ready) return;
await Purchases.logIn({ appUserID });
}
export async function logOutRevenueCat(): Promise<void> {
if (!isNativePlatform()) return;
if (!configured) return;
try {
await Purchases.logOut();
} catch (error) {
// Anonymous / already logged out — ignore.
console.warn('RevenueCat logOut', error);
}
}
function packageMatchesPlan(pkg: PurchasesPackage, planSlug: string): boolean {
const slug = planSlug.toLowerCase();
const id = (pkg.identifier || '').toLowerCase();
const productId = (pkg.product?.identifier || '').toLowerCase();
return id === slug || id.includes(slug) || productId.includes(slug);
}
async function resolvePackageForPlan(
planSlug?: string
): Promise<PurchasesPackage> {
const offerings = await Purchases.getOfferings();
const offeringId = (process.env.REACT_APP_REVENUECAT_OFFERING_ID || '').trim();
const offering =
(offeringId && offerings.all?.[offeringId]) || offerings.current || null;
if (!offering) {
throw new Error('No RevenueCat offerings available. Check the RC dashboard.');
}
const packages = offering.availablePackages || [];
if (!packages.length) {
throw new Error('RevenueCat offering has no packages.');
}
if (planSlug) {
const match = packages.find((pkg) => packageMatchesPlan(pkg, planSlug));
if (match) return match;
throw new Error(`No store package matches plan "${planSlug}".`);
}
return (
offering.monthly ||
packages.find((pkg) => (pkg.identifier || '').includes('monthly')) ||
packages[0]
);
}
export async function purchasePlan(planSlug?: string): Promise<void> {
if (!isNativePlatform()) {
throw new Error('Store purchases are only available in the mobile app.');
}
const ready = await configureRevenueCat();
if (!ready) {
throw new Error('RevenueCat is not configured. Missing API key.');
}
const aPackage = await resolvePackageForPlan(planSlug);
await Purchases.purchasePackage({ aPackage });
}
export async function restorePurchases(): Promise<void> {
if (!isNativePlatform()) {
throw new Error('Restore is only available in the mobile app.');
}
const ready = await configureRevenueCat();
if (!ready) {
throw new Error('RevenueCat is not configured. Missing API key.');
}
await Purchases.restorePurchases();
}
export function isPurchaseCancelledError(error: unknown): boolean {
const err = error as { userCancelled?: boolean; code?: number | string };
if (err?.userCancelled === true) return true;
// PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR === 1
return err?.code === 1 || err?.code === '1' || err?.code === 'PURCHASE_CANCELLED';
}
+46
View File
@@ -1,5 +1,6 @@
import { import {
parseCitationsData, parseCitationsData,
parseStatusData,
parseVersionedFrame, parseVersionedFrame,
} from './wsFrames'; } from './wsFrames';
@@ -49,4 +50,49 @@ describe('wsFrames', () => {
expect(parseCitationsData(null)).toEqual([]); expect(parseCitationsData(null)).toEqual([]);
expect(parseCitationsData('nope')).toEqual([]); expect(parseCitationsData('nope')).toEqual([]);
}); });
it('parses a status frame', () => {
const raw = JSON.stringify({
v: 1,
type: 'status',
data: { stage: 'searching', label: 'Searching the web', detail: 'query: cats' },
});
const frame = parseVersionedFrame(raw);
expect(frame?.type).toBe('status');
expect(parseStatusData(frame!.data)).toEqual({
stage: 'searching',
label: 'Searching the web',
detail: 'query: cats',
});
});
it('parses status data with no detail as null', () => {
expect(parseStatusData({ stage: 'writing', label: 'Writing the answer' })).toEqual({
stage: 'writing',
label: 'Writing the answer',
detail: null,
});
});
it('passes through unknown/future stage values verbatim', () => {
expect(
parseStatusData({ stage: 'some_future_stage', label: 'Doing something new' }),
).toEqual({
stage: 'some_future_stage',
label: 'Doing something new',
detail: null,
});
});
it('returns null for malformed status data without throwing', () => {
expect(parseStatusData(null)).toBeNull();
expect(parseStatusData(undefined)).toBeNull();
expect(parseStatusData('nope')).toBeNull();
expect(parseStatusData([])).toBeNull();
expect(parseStatusData({})).toBeNull();
expect(parseStatusData({ stage: 'searching' })).toBeNull();
expect(parseStatusData({ label: 'Searching the web' })).toBeNull();
expect(parseStatusData({ stage: 123, label: 'Searching' })).toBeNull();
expect(parseStatusData({ stage: 'searching', label: 42 })).toBeNull();
});
}); });
+23
View File
@@ -39,6 +39,29 @@ export function parseVersionedFrame(raw: string): VersionedFrame | null {
return null; return null;
} }
export type StatusFrameData = {
stage: string;
label: string;
detail?: string | null;
};
export type ActivityHistoryEntry = {
stage: string;
label: string;
startedAt: number;
finishedAt: number;
};
export function parseStatusData(data: unknown): StatusFrameData | null {
if (!data || typeof data !== 'object') return null;
const obj = data as Record<string, unknown>;
if (typeof obj.stage !== 'string' || !obj.stage) return null;
// label may be empty (e.g. stage "writing" — tokens take over)
if (typeof obj.label !== 'string') return null;
const detail = typeof obj.detail === 'string' ? obj.detail : null;
return { stage: obj.stage, label: obj.label, detail };
}
export function parseCitationsData(data: unknown): Citation[] { export function parseCitationsData(data: unknown): Citation[] {
if (!Array.isArray(data)) return []; if (!Array.isArray(data)) return [];
return data return data
+1
View File
@@ -9,6 +9,7 @@ interface CapacitorPreferencesPlugin {
interface CapacitorBridge { interface CapacitorBridge {
isNativePlatform?: () => boolean; isNativePlatform?: () => boolean;
isNative?: boolean; isNative?: boolean;
getPlatform?: () => string;
Plugins?: { Plugins?: {
Preferences?: CapacitorPreferencesPlugin; Preferences?: CapacitorPreferencesPlugin;
App?: { addListener?: (...args: unknown[]) => unknown }; App?: { addListener?: (...args: unknown[]) => unknown };
+13
View File
@@ -3,3 +3,16 @@
// expect(element).toHaveTextContent(/react/i) // expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom // learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
// Capacitor plugin ships ESM; Jest cannot parse it when AccountContext
// (or pages that import it) pull in utils/revenueCat. Stub globally.
jest.mock('@revenuecat/purchases-capacitor', () => ({
Purchases: {
configure: jest.fn(() => Promise.resolve()),
logIn: jest.fn(() => Promise.resolve({ customerInfo: {} })),
logOut: jest.fn(() => Promise.resolve({ customerInfo: {} })),
getOfferings: jest.fn(() => Promise.resolve({ current: null, all: {} })),
purchasePackage: jest.fn(() => Promise.resolve({ customerInfo: {} })),
restorePurchases: jest.fn(() => Promise.resolve({ customerInfo: {} })),
},
}));