Compare commits
1
Commits
master
..
5c70bb4b2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c70bb4b2d |
@@ -4,9 +4,3 @@
|
|||||||
# 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
-12
@@ -20,18 +20,7 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
|
|||||||
| Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` |
|
| Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` |
|
||||||
| Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` |
|
| Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` |
|
||||||
| ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — |
|
| ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — |
|
||||||
| Billing Portal Opened | `BILLING_PORTAL_OPENED` | BillingSection (Account) | `{ intent? }` |
|
| Billing Portal Opened | `BILLING_PORTAL_OPENED` | BillingSection (Account) | — |
|
||||||
| Subscription Upgrade Started | `SUBSCRIPTION_UPGRADE_STARTED` | BillingSection, UsageSummaryCard | `{ source, plan_slug? }` |
|
|
||||||
| Plan Change Started | `PLAN_CHANGE_STARTED` | BillingSection | `{ source, plan_slug? }` |
|
|
||||||
| Subscription Cancel Started | `SUBSCRIPTION_CANCEL_STARTED` | BillingSection | `{ source: 'portal' }` |
|
|
||||||
| Account Delete Started | `ACCOUNT_DELETE_STARTED` | DeleteAccountSection | — |
|
|
||||||
| Account Delete Success | `ACCOUNT_DELETE_SUCCESS` | DeleteAccountSection | — |
|
|
||||||
| Account Delete Failed | `ACCOUNT_DELETE_FAILED` | DeleteAccountSection | — |
|
|
||||||
| Message Copied | `MESSAGE_COPIED` | MessageActions | `{ role }` |
|
|
||||||
| Message Rated | `MESSAGE_RATED` | MessageActions | `{ rating: 'up' \| 'down' \| 'cleared' }` |
|
|
||||||
| Message Rating Reason | `MESSAGE_RATING_REASON` | MessageActions | `{ reason, hasComment }` |
|
|
||||||
| Message Exported | `MESSAGE_EXPORTED` | MessageActions, AsyncDashboard2 | `{ format, scope }` |
|
|
||||||
| Activity Stage Completed | `ACTIVITY_STAGE_COMPLETED` | MessageContext | `{ stage, durationMs }` (no label/detail text) |
|
|
||||||
|
|
||||||
## Identify
|
## Identify
|
||||||
|
|
||||||
|
|||||||
+3
-25
@@ -40,20 +40,6 @@ 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`:
|
||||||
@@ -132,20 +118,12 @@ 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. **Account deletion URL** (Data safety / Account deletion) → public page:
|
3. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView)
|
||||||
- Canonical: `https://hesychia.ai/account-deletion/`
|
4. Screenshots for phone (+ tablet if targeting)
|
||||||
- Legacy host until DNS cutover: `https://chat.aimloperations.com/account-deletion/`
|
5. Upload `.aab` to **internal testing** track before production
|
||||||
- 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
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -107,15 +107,9 @@ 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. Set **Account deletion URL** to the public instructions page (no sign-in required):
|
3. **Testing → Internal testing** → create release → upload `app-release.aab`.
|
||||||
- `https://hesychia.ai/account-deletion/` (preferred product host)
|
4. Add testers, roll out internal track, verify on devices.
|
||||||
- or `https://chat.aimloperations.com/account-deletion/` until hesychia.ai cutover
|
5. Promote to closed / open / production only after internal QA passes.
|
||||||
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`.
|
||||||
|
|
||||||
|
|||||||
Generated
+8
-409
@@ -21,14 +21,11 @@
|
|||||||
"@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",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/papaparse": "^5.5.2",
|
|
||||||
"@types/pdfmake": "^0.3.3",
|
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"babel-loader": "^9.2.1",
|
"babel-loader": "^9.2.1",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
@@ -39,8 +36,6 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"markdown-to-jsx": "^7.7.2",
|
"markdown-to-jsx": "^7.7.2",
|
||||||
"mini.css": "^3.0.1",
|
"mini.css": "^3.0.1",
|
||||||
"papaparse": "^5.5.4",
|
|
||||||
"pdfmake": "^0.3.11",
|
|
||||||
"react-bootstrap": "^2.10.6",
|
"react-bootstrap": "^2.10.6",
|
||||||
"react-code-blocks": "^0.1.6",
|
"react-code-blocks": "^0.1.6",
|
||||||
"react-github-btn": "^1.4.0",
|
"react-github-btn": "^1.4.0",
|
||||||
@@ -53,12 +48,10 @@
|
|||||||
"web-vitals": "^4.2.4",
|
"web-vitals": "^4.2.4",
|
||||||
"webpack": "^5.97.1",
|
"webpack": "^5.97.1",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^5.1.4",
|
||||||
"xlsx": "^0.18.5",
|
|
||||||
"yup": "^1.5.0"
|
"yup": "^1.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@testing-library/dom": "^10.4.1",
|
|
||||||
"@types/bootstrap": "~5.2.10",
|
"@types/bootstrap": "~5.2.10",
|
||||||
"@types/lodash": "~4.17.13",
|
"@types/lodash": "~4.17.13",
|
||||||
"@types/react": "^18.3.16",
|
"@types/react": "^18.3.16",
|
||||||
@@ -3259,9 +3252,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3279,9 +3269,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3299,9 +3286,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3319,9 +3303,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3339,9 +3320,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3359,9 +3337,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3379,9 +3354,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3399,9 +3371,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3419,9 +3388,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3445,9 +3411,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3471,9 +3434,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3497,9 +3457,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3523,9 +3480,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3549,9 +3503,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3575,9 +3526,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3601,9 +3549,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5026,30 +4971,6 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/ciphers": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^14.21.3 || >=16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@noble/hashes": {
|
|
||||||
"version": "1.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
|
||||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^14.21.3 || >=16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -5239,24 +5160,6 @@
|
|||||||
"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",
|
||||||
@@ -5605,18 +5508,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
|
||||||
"license": "MIT",
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@types/aria-query": "^5.0.1",
|
"@types/aria-query": "^5.0.1",
|
||||||
"aria-query": "5.3.0",
|
"aria-query": "5.3.0",
|
||||||
|
"chalk": "^4.1.0",
|
||||||
"dom-accessibility-api": "^0.5.9",
|
"dom-accessibility-api": "^0.5.9",
|
||||||
"lz-string": "^1.5.0",
|
"lz-string": "^1.5.0",
|
||||||
"picocolors": "1.1.1",
|
|
||||||
"pretty-format": "^27.0.2"
|
"pretty-format": "^27.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6028,7 +5931,7 @@
|
|||||||
"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==",
|
||||||
"license": "MIT"
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
@@ -6374,39 +6277,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/papaparse": {
|
|
||||||
"version": "5.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
|
|
||||||
"integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/parse-json": {
|
"node_modules/@types/parse-json": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="
|
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/pdfkit": {
|
|
||||||
"version": "0.17.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
|
|
||||||
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/pdfmake": {
|
|
||||||
"version": "0.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/pdfmake/-/pdfmake-0.3.3.tgz",
|
|
||||||
"integrity": "sha512-ufbceB4Q3dKpmFUMKEbZvJcfLi8+C5b1+rTFVG6urtkS3FHwaTQH6NCh/iEEDINlApCxH/W3/rsq99pP5E9/JA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*",
|
|
||||||
"@types/pdfkit": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/prettier": {
|
"node_modules/@types/prettier": {
|
||||||
"version": "2.7.3",
|
"version": "2.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz",
|
||||||
@@ -7135,15 +7010,6 @@
|
|||||||
"node": ">=8.9"
|
"node": ">=8.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/adler-32": {
|
|
||||||
"version": "1.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
|
||||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@@ -8068,29 +7934,11 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brotli": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.1.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/browser-process-hrtime": {
|
"node_modules/browser-process-hrtime": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
|
||||||
"integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow=="
|
"integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow=="
|
||||||
},
|
},
|
||||||
"node_modules/browserify-zlib": {
|
|
||||||
"version": "0.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
|
||||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pako": "~1.0.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.7",
|
"version": "4.28.7",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||||
@@ -8320,19 +8168,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cfb": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"adler-32": "~1.3.0",
|
|
||||||
"crc-32": "~1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -8507,15 +8342,6 @@
|
|||||||
"wrap-ansi": "^7.0.0"
|
"wrap-ansi": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/clone": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/clone-deep": {
|
"node_modules/clone-deep": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
|
||||||
@@ -8546,15 +8372,6 @@
|
|||||||
"node": ">= 0.12.0"
|
"node": ">= 0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/codepage": {
|
|
||||||
"version": "1.15.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
|
||||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/collect-v8-coverage": {
|
"node_modules/collect-v8-coverage": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
|
||||||
@@ -9035,18 +8852,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/crc-32": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"crc32": "bin/crc32.njs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/create-require": {
|
"node_modules/create-require": {
|
||||||
"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",
|
||||||
@@ -9853,12 +9658,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||||
},
|
},
|
||||||
"node_modules/dfa": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/didyoumean": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -9924,7 +9723,7 @@
|
|||||||
"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==",
|
||||||
"license": "MIT"
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/dom-converter": {
|
"node_modules/dom-converter": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
@@ -11553,23 +11352,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fontkit": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@swc/helpers": "^0.5.12",
|
|
||||||
"brotli": "^1.3.2",
|
|
||||||
"clone": "^2.1.2",
|
|
||||||
"dfa": "^1.2.0",
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"restructure": "^3.0.0",
|
|
||||||
"tiny-inflate": "^1.0.3",
|
|
||||||
"unicode-properties": "^1.4.0",
|
|
||||||
"unicode-trie": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -11793,15 +11575,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/frac": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
@@ -15538,12 +15311,6 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/js-md5": {
|
|
||||||
"version": "0.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
|
||||||
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -15822,25 +15589,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/linebreak": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "0.0.8",
|
|
||||||
"unicode-trie": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/linebreak/node_modules/base64-js": {
|
|
||||||
"version": "0.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
|
||||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -16011,7 +15759,7 @@
|
|||||||
"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==",
|
||||||
"license": "MIT",
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
@@ -17096,18 +16844,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
|
||||||
},
|
},
|
||||||
"node_modules/pako": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
|
||||||
"license": "(MIT AND Zlib)"
|
|
||||||
},
|
|
||||||
"node_modules/papaparse": {
|
|
||||||
"version": "5.5.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz",
|
|
||||||
"integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/param-case": {
|
"node_modules/param-case": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||||
@@ -17247,34 +16983,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pdfkit": {
|
|
||||||
"version": "0.19.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
|
|
||||||
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@noble/ciphers": "^1.0.0",
|
|
||||||
"@noble/hashes": "^1.6.0",
|
|
||||||
"fontkit": "^2.0.4",
|
|
||||||
"js-md5": "^0.8.3",
|
|
||||||
"linebreak": "^1.1.0",
|
|
||||||
"png-js": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pdfmake": {
|
|
||||||
"version": "0.3.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/pdfmake/-/pdfmake-0.3.11.tgz",
|
|
||||||
"integrity": "sha512-Uc49J9hUMyuqJk+U+PxlpBpPr96A4HOOfesGx609EPr2ue82+5/Smq/KTAkEqh0/jUGSi1fumvqZ5yAWijJTJg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"linebreak": "^1.1.0",
|
|
||||||
"pdfkit": "^0.19.1",
|
|
||||||
"xmldoc": "^2.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pend": {
|
"node_modules/pend": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
@@ -17414,14 +17122,6 @@
|
|||||||
"node": ">=10.4.0"
|
"node": ">=10.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/png-js": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
|
|
||||||
"dependencies": {
|
|
||||||
"browserify-zlib": "^0.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -20378,12 +20078,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/restructure": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/retry": {
|
"node_modules/retry": {
|
||||||
"version": "0.13.1",
|
"version": "0.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||||
@@ -21308,18 +21002,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
|
||||||
},
|
},
|
||||||
"node_modules/ssf": {
|
|
||||||
"version": "0.11.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
|
||||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"frac": "~1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stable": {
|
"node_modules/stable": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
|
||||||
@@ -22179,12 +21861,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||||
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
|
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
|
||||||
},
|
},
|
||||||
"node_modules/tiny-inflate": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
@@ -22635,16 +22311,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unicode-properties": {
|
|
||||||
"version": "1.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
|
||||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.3.0",
|
|
||||||
"unicode-trie": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unicode-property-aliases-ecmascript": {
|
"node_modules/unicode-property-aliases-ecmascript": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
|
||||||
@@ -22653,22 +22319,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unicode-trie": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pako": "^0.2.5",
|
|
||||||
"tiny-inflate": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unicode-trie/node_modules/pako": {
|
|
||||||
"version": "0.2.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
|
||||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/unique-string": {
|
"node_modules/unique-string": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
|
||||||
@@ -23365,24 +23015,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
||||||
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ=="
|
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ=="
|
||||||
},
|
},
|
||||||
"node_modules/wmf": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/word": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -23779,27 +23411,6 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xlsx": {
|
|
||||||
"version": "0.18.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
|
||||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"adler-32": "~1.3.0",
|
|
||||||
"cfb": "~1.2.1",
|
|
||||||
"codepage": "~1.15.0",
|
|
||||||
"crc-32": "~1.2.1",
|
|
||||||
"ssf": "~0.11.2",
|
|
||||||
"wmf": "~1.0.1",
|
|
||||||
"word": "~0.3.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"xlsx": "bin/xlsx.njs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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",
|
||||||
@@ -23854,18 +23465,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
|
||||||
},
|
},
|
||||||
"node_modules/xmldoc": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"sax": "^1.4.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xpath": {
|
"node_modules/xpath": {
|
||||||
"version": "0.0.32",
|
"version": "0.0.32",
|
||||||
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
|
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
|
||||||
|
|||||||
@@ -16,14 +16,11 @@
|
|||||||
"@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",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/papaparse": "^5.5.2",
|
|
||||||
"@types/pdfmake": "^0.3.3",
|
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"babel-loader": "^9.2.1",
|
"babel-loader": "^9.2.1",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
@@ -34,8 +31,6 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"markdown-to-jsx": "^7.7.2",
|
"markdown-to-jsx": "^7.7.2",
|
||||||
"mini.css": "^3.0.1",
|
"mini.css": "^3.0.1",
|
||||||
"papaparse": "^5.5.4",
|
|
||||||
"pdfmake": "^0.3.11",
|
|
||||||
"react-bootstrap": "^2.10.6",
|
"react-bootstrap": "^2.10.6",
|
||||||
"react-code-blocks": "^0.1.6",
|
"react-code-blocks": "^0.1.6",
|
||||||
"react-github-btn": "^1.4.0",
|
"react-github-btn": "^1.4.0",
|
||||||
@@ -48,7 +43,6 @@
|
|||||||
"web-vitals": "^4.2.4",
|
"web-vitals": "^4.2.4",
|
||||||
"webpack": "^5.97.1",
|
"webpack": "^5.97.1",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^5.1.4",
|
||||||
"xlsx": "^0.18.5",
|
|
||||||
"yup": "^1.5.0"
|
"yup": "^1.5.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -86,7 +80,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@testing-library/dom": "^10.4.1",
|
|
||||||
"@types/bootstrap": "~5.2.10",
|
"@types/bootstrap": "~5.2.10",
|
||||||
"@types/lodash": "~4.17.13",
|
"@types/lodash": "~4.17.13",
|
||||||
"@types/react": "^18.3.16",
|
"@types/react": "^18.3.16",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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';
|
||||||
@@ -25,7 +24,6 @@ import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentCont
|
|||||||
import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner';
|
import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner';
|
||||||
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
|
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
|
||||||
import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary';
|
import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary';
|
||||||
import ToastHost from './llm-fe/components/ToastHost/ToastHost';
|
|
||||||
|
|
||||||
const ProtectedRoutes = () => {
|
const ProtectedRoutes = () => {
|
||||||
const { authenticated, loading } = useContext(AuthContext);
|
const { authenticated, loading } = useContext(AuthContext);
|
||||||
@@ -50,7 +48,6 @@ class App extends Component {
|
|||||||
<AnalyticsConsentProvider>
|
<AnalyticsConsentProvider>
|
||||||
<Tracker />
|
<Tracker />
|
||||||
<AnalyticsSession />
|
<AnalyticsSession />
|
||||||
<ToastHost />
|
|
||||||
<AnalyticsConsentBanner />
|
<AnalyticsConsentBanner />
|
||||||
<div className='site'>
|
<div className='site'>
|
||||||
<main>
|
<main>
|
||||||
@@ -72,8 +69,6 @@ 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} />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
/**
|
/**
|
||||||
* Absolute backend OAuth start URL.
|
* Absolute backend OAuth start URL.
|
||||||
* @param {'google'|'microsoft'} provider
|
* @param {'google'|'microsoft'} provider
|
||||||
* @param {'login'|'signup'|'link_drive'|'link_company_drive'} intent
|
* @param {'login'|'signup'} intent
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function oauthStartUrl(provider, intent = 'login') {
|
export function oauthStartUrl(provider, intent = 'login') {
|
||||||
@@ -18,7 +18,7 @@ export function oauthStartUrl(provider, intent = 'login') {
|
|||||||
/**
|
/**
|
||||||
* Begin browser redirect to IdP via backend.
|
* Begin browser redirect to IdP via backend.
|
||||||
* @param {'google'|'microsoft'} provider
|
* @param {'google'|'microsoft'} provider
|
||||||
* @param {'login'|'signup'|'link_drive'|'link_company_drive'} intent
|
* @param {'login'|'signup'} intent
|
||||||
*/
|
*/
|
||||||
export function startOAuth(provider, intent = 'login') {
|
export function startOAuth(provider, intent = 'login') {
|
||||||
window.location.assign(oauthStartUrl(provider, intent));
|
window.location.assign(oauthStartUrl(provider, intent));
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
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,9 +8,6 @@ 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: {
|
||||||
@@ -23,24 +20,10 @@ jest.mock('../../utils/analytics', () => ({
|
|||||||
AnalyticsEvents: {
|
AnalyticsEvents: {
|
||||||
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
||||||
CHECKOUT_STARTED: 'Checkout Started',
|
CHECKOUT_STARTED: 'Checkout Started',
|
||||||
SUBSCRIPTION_UPGRADE_STARTED: 'Subscription Upgrade Started',
|
|
||||||
PLAN_CHANGE_STARTED: 'Plan Change Started',
|
|
||||||
SUBSCRIPTION_CANCEL_STARTED: 'Subscription Cancel Started',
|
|
||||||
},
|
},
|
||||||
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,
|
||||||
@@ -69,7 +52,8 @@ const paidInvoice = {
|
|||||||
last_modified: '2026-07-01T12:00:00Z',
|
last_modified: '2026-07-01T12:00:00Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
const foundersPlan = {
|
const foundersSubscription = {
|
||||||
|
plan: {
|
||||||
slug: 'founders',
|
slug: 'founders',
|
||||||
name: 'Founders',
|
name: 'Founders',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -87,16 +71,11 @@ const foundersPlan = {
|
|||||||
prompt_window_hours: 6,
|
prompt_window_hours: 6,
|
||||||
monthly_token_quota: null,
|
monthly_token_quota: null,
|
||||||
sort_order: 10,
|
sort_order: 10,
|
||||||
};
|
},
|
||||||
|
|
||||||
const foundersSubscription = {
|
|
||||||
plan: foundersPlan,
|
|
||||||
status: 'active',
|
status: 'active',
|
||||||
source: 'stripe',
|
source: 'stripe',
|
||||||
needs_checkout: false,
|
needs_checkout: false,
|
||||||
stripe_subscription_id: 'sub_test',
|
stripe_subscription_id: 'sub_test',
|
||||||
cancel_at_period_end: false,
|
|
||||||
current_period_end: '2026-08-01T00:00:00Z',
|
|
||||||
usage: {
|
usage: {
|
||||||
prompts_in_window: 2,
|
prompts_in_window: 2,
|
||||||
prompt_quota: 300,
|
prompt_quota: 300,
|
||||||
@@ -113,20 +92,12 @@ const foundersSubscription = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const complimentarySubscription = {
|
|
||||||
...foundersSubscription,
|
|
||||||
source: 'backer',
|
|
||||||
stripe_subscription_id: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptySubscription = {
|
const emptySubscription = {
|
||||||
plan: null,
|
plan: null,
|
||||||
status: 'none',
|
status: 'none',
|
||||||
source: 'none',
|
source: 'none',
|
||||||
needs_checkout: true,
|
needs_checkout: true,
|
||||||
stripe_subscription_id: '',
|
stripe_subscription_id: '',
|
||||||
cancel_at_period_end: false,
|
|
||||||
current_period_end: null,
|
|
||||||
usage: {
|
usage: {
|
||||||
prompts_in_window: 0,
|
prompts_in_window: 0,
|
||||||
prompt_quota: null,
|
prompt_quota: null,
|
||||||
@@ -149,26 +120,21 @@ const mockFinanceGets = ({
|
|||||||
invoices = [] as unknown[],
|
invoices = [] as unknown[],
|
||||||
payments = [] as unknown[],
|
payments = [] as unknown[],
|
||||||
subscription = emptySubscription as SubscriptionMock,
|
subscription = emptySubscription as SubscriptionMock,
|
||||||
plans = [foundersPlan] as unknown[],
|
|
||||||
}: {
|
}: {
|
||||||
invoices?: unknown[];
|
invoices?: unknown[];
|
||||||
payments?: unknown[];
|
payments?: unknown[];
|
||||||
subscription?: SubscriptionMock;
|
subscription?: SubscriptionMock;
|
||||||
plans?: unknown[];
|
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
mockGet.mockImplementation((url: string) => {
|
mockGet.mockImplementation((url: string) => {
|
||||||
if (url === '/monetization/invoices/') {
|
if (url === '/finance/invoices/') {
|
||||||
return Promise.resolve({ data: invoices });
|
return Promise.resolve({ data: invoices });
|
||||||
}
|
}
|
||||||
if (url === '/monetization/payments/') {
|
if (url === '/finance/payments/') {
|
||||||
return Promise.resolve({ data: payments });
|
return Promise.resolve({ data: payments });
|
||||||
}
|
}
|
||||||
if (url === '/monetization/subscription/') {
|
if (url === '/finance/subscription/') {
|
||||||
return Promise.resolve({ data: subscription });
|
return Promise.resolve({ data: subscription });
|
||||||
}
|
}
|
||||||
if (url === '/monetization/plans/') {
|
|
||||||
return Promise.resolve({ data: plans });
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`unexpected GET ${url}`));
|
return Promise.reject(new Error(`unexpected GET ${url}`));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -188,9 +154,6 @@ 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: {
|
||||||
@@ -208,7 +171,7 @@ describe('BillingSection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders plan summary and paid subscription management CTAs', async () => {
|
it('renders plan summary and invoice history from finance APIs', async () => {
|
||||||
mockFinanceGets({
|
mockFinanceGets({
|
||||||
invoices: [paidInvoice],
|
invoices: [paidInvoice],
|
||||||
subscription: foundersSubscription,
|
subscription: foundersSubscription,
|
||||||
@@ -216,10 +179,7 @@ describe('BillingSection', () => {
|
|||||||
|
|
||||||
renderBilling();
|
renderBilling();
|
||||||
|
|
||||||
expect(await screen.findByRole('button', { name: /^Upgrade$/i })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: /Manage subscription/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /Change plan/i })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: /^Cancel$/i })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: /Manage subscription/i })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Founders')).toBeInTheDocument();
|
expect(screen.getByText('Founders')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/298 \/ 300/)).toBeInTheDocument();
|
expect(screen.getByText(/298 \/ 300/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/in — · out —/)).toBeInTheDocument();
|
expect(screen.getByText(/in — · out —/)).toBeInTheDocument();
|
||||||
@@ -229,11 +189,10 @@ describe('BillingSection', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens Stripe portal for change plan when no alternate public plans', async () => {
|
it('redirects to Stripe Customer Portal on manage billing', async () => {
|
||||||
mockFinanceGets({
|
mockFinanceGets({
|
||||||
invoices: [paidInvoice],
|
invoices: [paidInvoice],
|
||||||
subscription: foundersSubscription,
|
subscription: foundersSubscription,
|
||||||
plans: [foundersPlan],
|
|
||||||
});
|
});
|
||||||
mockPost.mockResolvedValue({
|
mockPost.mockResolvedValue({
|
||||||
data: { portal_url: 'https://billing.stripe.com/p/session/test' },
|
data: { portal_url: 'https://billing.stripe.com/p/session/test' },
|
||||||
@@ -242,67 +201,20 @@ describe('BillingSection', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderBilling();
|
renderBilling();
|
||||||
|
|
||||||
await screen.findByRole('button', { name: /Change plan/i });
|
await screen.findByRole('button', { name: /Manage subscription/i });
|
||||||
await user.click(screen.getByRole('button', { name: /Change plan/i }));
|
await user.click(screen.getByRole('button', { name: /Manage subscription/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockPost).toHaveBeenCalledWith('/monetization/portal/', {
|
expect(mockPost).toHaveBeenCalledWith('/finance/portal/', {
|
||||||
return_url: 'http://localhost/account/',
|
return_url: 'http://localhost/account/',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(mockTrackEvent).toHaveBeenCalledWith('Plan Change Started', {
|
expect(mockTrackEvent).toHaveBeenCalledWith('Billing Portal Opened');
|
||||||
source: 'account_billing',
|
|
||||||
});
|
|
||||||
expect(mockTrackEvent).toHaveBeenCalledWith('Billing Portal Opened', {
|
|
||||||
intent: 'change',
|
|
||||||
});
|
|
||||||
expect(assignMock).toHaveBeenCalledWith(
|
expect(assignMock).toHaveBeenCalledWith(
|
||||||
'https://billing.stripe.com/p/session/test'
|
'https://billing.stripe.com/p/session/test'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('confirms cancel then opens portal', async () => {
|
|
||||||
mockFinanceGets({
|
|
||||||
invoices: [paidInvoice],
|
|
||||||
subscription: foundersSubscription,
|
|
||||||
});
|
|
||||||
mockPost.mockResolvedValue({
|
|
||||||
data: { portal_url: 'https://billing.stripe.com/p/session/cancel' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderBilling();
|
|
||||||
|
|
||||||
await screen.findByRole('button', { name: /^Cancel$/i });
|
|
||||||
await user.click(screen.getByRole('button', { name: /^Cancel$/i }));
|
|
||||||
expect(await screen.findByTestId('cancel-confirm-modal')).toBeInTheDocument();
|
|
||||||
await user.click(screen.getByRole('button', { name: /Continue to cancel/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockTrackEvent).toHaveBeenCalledWith('Subscription Cancel Started', {
|
|
||||||
source: 'portal',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(assignMock).toHaveBeenCalledWith(
|
|
||||||
'https://billing.stripe.com/p/session/cancel'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows complimentary messaging without cancel', async () => {
|
|
||||||
mockFinanceGets({
|
|
||||||
invoices: [],
|
|
||||||
subscription: complimentarySubscription,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderBilling();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByTestId('complimentary-billing-message')
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('button', { name: /^Cancel$/i })).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('button', { name: /^Upgrade$/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows empty unpaid state and starts checkout', async () => {
|
it('shows empty unpaid state and starts checkout', async () => {
|
||||||
mockFinanceGets();
|
mockFinanceGets();
|
||||||
mockPost.mockResolvedValue({
|
mockPost.mockResolvedValue({
|
||||||
@@ -321,7 +233,7 @@ describe('BillingSection', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockPost).toHaveBeenCalledWith(
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
'/monetization/checkout/',
|
'/finance/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'),
|
||||||
@@ -362,49 +274,4 @@ 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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,23 +10,10 @@ import {
|
|||||||
formatBillingDate,
|
formatBillingDate,
|
||||||
formatMoneyCents,
|
formatMoneyCents,
|
||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
higherSelectablePlans,
|
|
||||||
humanizeStatus,
|
humanizeStatus,
|
||||||
invoiceProviderLabel,
|
|
||||||
isComplimentarySubscription,
|
|
||||||
isStoreSubscription,
|
|
||||||
isStripeSubscription,
|
|
||||||
otherSelectablePlans,
|
|
||||||
pickPrimaryInvoice,
|
pickPrimaryInvoice,
|
||||||
SubscriptionMe,
|
SubscriptionMe,
|
||||||
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};
|
||||||
@@ -82,14 +69,6 @@ const BodyText = styled.p`
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NoticeText = styled.p`
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.85;
|
|
||||||
margin: 1rem 0 0 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ErrorText = styled.p`
|
const ErrorText = styled.p`
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
margin: 0.75rem 0 0 0;
|
margin: 0.75rem 0 0 0;
|
||||||
@@ -137,81 +116,6 @@ const SecondaryButton = styled(StyledButton)`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DangerButton = styled(SecondaryButton)`
|
|
||||||
border-color: #ff6b6b66;
|
|
||||||
color: #ff6b6b;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #ff6b6b;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PlanList = styled.ul`
|
|
||||||
list-style: none;
|
|
||||||
margin: 1rem 0 0 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PlanOption = styled.li`
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PlanMeta = styled.div`
|
|
||||||
flex: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PlanName = styled.div`
|
|
||||||
font-weight: 600;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PlanDesc = styled.div`
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.7;
|
|
||||||
line-height: 1.4;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ModalBackdrop = styled.div`
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 1.5rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ModalCard = styled.div`
|
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 1.75rem;
|
|
||||||
max-width: 480px;
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ModalTitle = styled.h3`
|
|
||||||
margin: 0 0 0.75rem 0;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
font-size: 1.35rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledTable = styled.table`
|
const StyledTable = styled.table`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -242,14 +146,6 @@ 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 } };
|
||||||
@@ -258,54 +154,32 @@ 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';
|
|
||||||
|
|
||||||
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);
|
||||||
const [plans, setPlans] = useState<SubscriptionPlanInfo[]>([]);
|
|
||||||
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 [checkoutLoading, setCheckoutLoading] = useState(false);
|
||||||
const [restoreLoading, setRestoreLoading] = useState(false);
|
|
||||||
const [showPlanPicker, setShowPlanPicker] = useState(false);
|
|
||||||
const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change');
|
|
||||||
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
|
|
||||||
|
|
||||||
const loadBilling = useCallback(async () => {
|
const loadBilling = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setListError('');
|
setListError('');
|
||||||
try {
|
try {
|
||||||
const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] =
|
const [invoiceResponse, paymentResponse, subscriptionResponse] = 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[]>('/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 : []);
|
||||||
setSubscription(subscriptionResponse.data || null);
|
setSubscription(subscriptionResponse.data || null);
|
||||||
setPlans(Array.isArray(plansResponse.data) ? plansResponse.data : []);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setInvoices([]);
|
setInvoices([]);
|
||||||
setPayments([]);
|
setPayments([]);
|
||||||
setSubscription(null);
|
setSubscription(null);
|
||||||
setPlans([]);
|
|
||||||
setListError(apiErrorMessage(error, 'Could not load billing information.'));
|
setListError(apiErrorMessage(error, 'Could not load billing information.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -318,34 +192,6 @@ const BillingSection = (): JSX.Element => {
|
|||||||
|
|
||||||
const primaryInvoice = useMemo(() => pickPrimaryInvoice(invoices), [invoices]);
|
const primaryInvoice = useMemo(() => pickPrimaryInvoice(invoices), [invoices]);
|
||||||
const hasPortalAccess = useMemo(() => canOpenBillingPortal(invoices), [invoices]);
|
const hasPortalAccess = useMemo(() => canOpenBillingPortal(invoices), [invoices]);
|
||||||
const complimentary = useMemo(
|
|
||||||
() => isComplimentarySubscription(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(
|
|
||||||
() => higherSelectablePlans(plans, subscription?.plan),
|
|
||||||
[plans, subscription?.plan]
|
|
||||||
);
|
|
||||||
const changePlans = useMemo(
|
|
||||||
() => otherSelectablePlans(plans, subscription?.plan?.slug),
|
|
||||||
[plans, subscription?.plan?.slug]
|
|
||||||
);
|
|
||||||
const periodEndLabel = useMemo(() => {
|
|
||||||
const end =
|
|
||||||
subscription?.current_period_end || primaryInvoice?.period_end || null;
|
|
||||||
return formatBillingDate(end);
|
|
||||||
}, [subscription?.current_period_end, primaryInvoice?.period_end]);
|
|
||||||
const storeLabel = useMemo(() => storeManageLabel(), []);
|
|
||||||
|
|
||||||
const historyRows = useMemo(() => {
|
const historyRows = useMemo(() => {
|
||||||
if (invoices.length) {
|
if (invoices.length) {
|
||||||
@@ -353,7 +199,6 @@ 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
|
||||||
@@ -366,26 +211,19 @@ 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: '',
|
||||||
}));
|
}));
|
||||||
}, [invoices, payments]);
|
}, [invoices, payments]);
|
||||||
|
|
||||||
const openPortal = async (intent: PortalIntent) => {
|
const handleManageBilling = async () => {
|
||||||
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 }>(
|
||||||
'/monetization/portal/',
|
'/finance/portal/',
|
||||||
{ return_url: returnUrl }
|
{ return_url: returnUrl }
|
||||||
);
|
);
|
||||||
const portalUrl = response.data?.portal_url;
|
const portalUrl = response.data?.portal_url;
|
||||||
@@ -393,7 +231,7 @@ const BillingSection = (): JSX.Element => {
|
|||||||
setActionError('Billing portal could not be opened. Try again.');
|
setActionError('Billing portal could not be opened. Try again.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED, { intent });
|
trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED);
|
||||||
window.location.assign(portalUrl);
|
window.location.assign(portalUrl);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setActionError(
|
setActionError(
|
||||||
@@ -404,69 +242,15 @@ const BillingSection = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNativePurchase = async (planSlug?: string, source = 'account_billing') => {
|
const handleStartCheckout = async () => {
|
||||||
setActionError('');
|
setActionError('');
|
||||||
setActionNotice('');
|
setCheckoutLoading(true);
|
||||||
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__');
|
|
||||||
try {
|
try {
|
||||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, {
|
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'account_billing' });
|
||||||
source,
|
|
||||||
...(planSlug ? { plan_slug: planSlug } : {}),
|
|
||||||
});
|
|
||||||
const response = await axiosInstance.post<{ checkout_url: string }>(
|
const response = await axiosInstance.post<{ checkout_url: string }>(
|
||||||
'/monetization/checkout/',
|
'/finance/checkout/',
|
||||||
{
|
{ success_url, cancel_url }
|
||||||
success_url,
|
|
||||||
cancel_url,
|
|
||||||
...(planSlug ? { plan_slug: planSlug } : {}),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
const checkoutUrl = response.data?.checkout_url;
|
const checkoutUrl = response.data?.checkout_url;
|
||||||
if (!checkoutUrl) {
|
if (!checkoutUrl) {
|
||||||
@@ -477,81 +261,10 @@ const BillingSection = (): JSX.Element => {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setActionError(apiErrorMessage(error, 'Could not start checkout. Try again.'));
|
setActionError(apiErrorMessage(error, 'Could not start checkout. Try again.'));
|
||||||
} finally {
|
} finally {
|
||||||
setCheckoutLoadingSlug(null);
|
setCheckoutLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpgradeClick = () => {
|
|
||||||
trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, {
|
|
||||||
source: 'account_billing',
|
|
||||||
});
|
|
||||||
if (upgradePlans.length > 0) {
|
|
||||||
setPlanPickerMode('upgrade');
|
|
||||||
setShowPlanPicker(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (native && (storeSub || showStoreManage)) {
|
|
||||||
void handleNativePurchase(undefined, 'account_upgrade');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void openPortal('upgrade');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePlanClick = () => {
|
|
||||||
trackEvent(AnalyticsEvents.PLAN_CHANGE_STARTED, { source: 'account_billing' });
|
|
||||||
if (changePlans.length > 0) {
|
|
||||||
setPlanPickerMode('change');
|
|
||||||
setShowPlanPicker(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (native && (storeSub || showStoreManage)) {
|
|
||||||
setActionNotice(
|
|
||||||
`Change or cancel your plan in ${storeLabel} subscription settings.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void openPortal('change');
|
|
||||||
};
|
|
||||||
|
|
||||||
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' });
|
|
||||||
setCancelConfirmOpen(false);
|
|
||||||
await openPortal('cancel');
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickerPlans = planPickerMode === 'upgrade' ? upgradePlans : changePlans;
|
|
||||||
|
|
||||||
const selectPlanFromPicker = async (plan: SubscriptionPlanInfo) => {
|
|
||||||
if (planPickerMode === 'upgrade') {
|
|
||||||
trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, {
|
|
||||||
source: 'plan_picker',
|
|
||||||
plan_slug: plan.slug,
|
|
||||||
});
|
|
||||||
await handleStartCheckout(plan.slug, 'account_upgrade');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
trackEvent(AnalyticsEvents.PLAN_CHANGE_STARTED, {
|
|
||||||
source: 'plan_picker',
|
|
||||||
plan_slug: plan.slug,
|
|
||||||
});
|
|
||||||
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);
|
|
||||||
await openPortal('change');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlassCard data-testid="billing-section">
|
<GlassCard data-testid="billing-section">
|
||||||
@@ -618,19 +331,13 @@ const BillingSection = (): JSX.Element => {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{primaryInvoice ? (
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingLabel>Period end</SettingLabel>
|
<SettingLabel>Period end</SettingLabel>
|
||||||
<SettingValue>{periodEndLabel}</SettingValue>
|
<SettingValue>
|
||||||
|
{formatBillingDate(primaryInvoice.period_end)}
|
||||||
|
</SettingValue>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{subscription?.cancel_at_period_end ? (
|
|
||||||
<NoticeText data-testid="cancel-scheduled-notice">
|
|
||||||
Cancellation scheduled. You keep access until {periodEndLabel}.
|
|
||||||
</NoticeText>
|
|
||||||
) : null}
|
|
||||||
{subscription?.status === 'canceled' ? (
|
|
||||||
<NoticeText>
|
|
||||||
Subscription canceled. Renew via Complete payment when you are ready.
|
|
||||||
</NoticeText>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -642,172 +349,32 @@ const BillingSection = (): JSX.Element => {
|
|||||||
|
|
||||||
{!loading && !listError && (
|
{!loading && !listError && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
{showStripeManage ? (
|
{hasPortalAccess ? (
|
||||||
<>
|
|
||||||
<StyledButton
|
<StyledButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleUpgradeClick}
|
onClick={handleManageBilling}
|
||||||
disabled={portalLoading || Boolean(checkoutLoadingSlug)}
|
|
||||||
>
|
|
||||||
Upgrade
|
|
||||||
</StyledButton>
|
|
||||||
<SecondaryButton
|
|
||||||
type="button"
|
|
||||||
onClick={handleChangePlanClick}
|
|
||||||
disabled={portalLoading || Boolean(checkoutLoadingSlug)}
|
|
||||||
>
|
|
||||||
Change plan
|
|
||||||
</SecondaryButton>
|
|
||||||
{!subscription?.cancel_at_period_end &&
|
|
||||||
subscription?.status !== 'canceled' ? (
|
|
||||||
<DangerButton
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCancelConfirmOpen(true)}
|
|
||||||
disabled={portalLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</DangerButton>
|
|
||||||
) : null}
|
|
||||||
<SecondaryButton
|
|
||||||
type="button"
|
|
||||||
onClick={() => openPortal('manage')}
|
|
||||||
disabled={portalLoading}
|
disabled={portalLoading}
|
||||||
>
|
>
|
||||||
{portalLoading ? 'Opening…' : 'Manage subscription'}
|
{portalLoading ? 'Opening…' : 'Manage subscription'}
|
||||||
</SecondaryButton>
|
</StyledButton>
|
||||||
</>
|
) : subscription?.needs_checkout === false ? (
|
||||||
) : complimentary ? (
|
<BodyText style={{ margin: 0 }}>
|
||||||
<BodyText style={{ margin: 0 }} data-testid="complimentary-billing-message">
|
Complimentary access — no payment required.
|
||||||
Complimentary access — no payment required. Plan changes and
|
|
||||||
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) || restoreLoading}
|
disabled={checkoutLoading}
|
||||||
>
|
>
|
||||||
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
|
{checkoutLoading ? '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
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</ButtonRow>
|
</ButtonRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showPlanPicker && pickerPlans.length > 0 ? (
|
|
||||||
<div data-testid="plan-picker">
|
|
||||||
<BodyText style={{ marginTop: '1.25rem', marginBottom: 0 }}>
|
|
||||||
{planPickerMode === 'upgrade'
|
|
||||||
? 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).'}
|
|
||||||
</BodyText>
|
|
||||||
<PlanList>
|
|
||||||
{pickerPlans.map((plan) => (
|
|
||||||
<PlanOption key={plan.slug}>
|
|
||||||
<PlanMeta>
|
|
||||||
<PlanName>{plan.name}</PlanName>
|
|
||||||
<PlanDesc>
|
|
||||||
{formatMoneyCents(plan.price_cents, plan.currency)}
|
|
||||||
{plan.interval ? ` / ${plan.interval}` : ''}
|
|
||||||
{plan.description ? ` — ${plan.description}` : ''}
|
|
||||||
{` · ${plan.prompt_quota_per_window} prompts / ${plan.prompt_window_hours}h`}
|
|
||||||
</PlanDesc>
|
|
||||||
</PlanMeta>
|
|
||||||
<StyledButton
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectPlanFromPicker(plan)}
|
|
||||||
disabled={
|
|
||||||
portalLoading ||
|
|
||||||
checkoutLoadingSlug === plan.slug ||
|
|
||||||
checkoutLoadingSlug === '__default__'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{checkoutLoadingSlug === plan.slug
|
|
||||||
? 'Starting…'
|
|
||||||
: planPickerMode === 'upgrade'
|
|
||||||
? 'Upgrade'
|
|
||||||
: 'Select'}
|
|
||||||
</StyledButton>
|
|
||||||
</PlanOption>
|
|
||||||
))}
|
|
||||||
</PlanList>
|
|
||||||
<ButtonRow>
|
|
||||||
<SecondaryButton type="button" onClick={() => setShowPlanPicker(false)}>
|
|
||||||
Close
|
|
||||||
</SecondaryButton>
|
|
||||||
</ButtonRow>
|
|
||||||
</div>
|
|
||||||
) : 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>
|
||||||
|
|
||||||
@@ -825,7 +392,6 @@ 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>
|
||||||
@@ -836,9 +402,6 @@ 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>
|
||||||
@@ -858,48 +421,6 @@ const BillingSection = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
{cancelConfirmOpen ? (
|
|
||||||
<ModalBackdrop
|
|
||||||
role="presentation"
|
|
||||||
onClick={() => setCancelConfirmOpen(false)}
|
|
||||||
data-testid="cancel-confirm-modal"
|
|
||||||
>
|
|
||||||
<ModalCard
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="cancel-subscription-title"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle>
|
|
||||||
<BodyText>
|
|
||||||
{native && (storeSub || showStoreManage) && !showStripeManage
|
|
||||||
? `You will cancel in ${storeLabel} subscription settings. Access typically continues until the end of the current billing period${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>
|
|
||||||
<ButtonRow>
|
|
||||||
<DangerButton
|
|
||||||
type="button"
|
|
||||||
onClick={handleConfirmCancel}
|
|
||||||
disabled={portalLoading}
|
|
||||||
>
|
|
||||||
{native && (storeSub || showStoreManage) && !showStripeManage
|
|
||||||
? 'Got it'
|
|
||||||
: portalLoading
|
|
||||||
? 'Opening…'
|
|
||||||
: 'Continue to cancel'}
|
|
||||||
</DangerButton>
|
|
||||||
<SecondaryButton
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCancelConfirmOpen(false)}
|
|
||||||
disabled={portalLoading}
|
|
||||||
>
|
|
||||||
Keep subscription
|
|
||||||
</SecondaryButton>
|
|
||||||
</ButtonRow>
|
|
||||||
</ModalCard>
|
|
||||||
</ModalBackdrop>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
-74
@@ -1,74 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { ThemeProvider } from 'styled-components';
|
|
||||||
import ConversationDetailCard, {
|
|
||||||
injectCitationMarkers,
|
|
||||||
} from './ConversationDetailCard';
|
|
||||||
|
|
||||||
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 renderCard = (props: React.ComponentProps<typeof ConversationDetailCard>) =>
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ThemeProvider theme={theme as never}>
|
|
||||||
<ConversationDetailCard {...props} />
|
|
||||||
</ThemeProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('ConversationDetailCard citations (#98)', () => {
|
|
||||||
it('injects citation markers outside code fences', () => {
|
|
||||||
const md = 'See [1] and [2, 3]\n\n```\n[9]\n```';
|
|
||||||
const out = injectCitationMarkers(md);
|
|
||||||
expect(out).toContain('<citation indices="1"></citation>');
|
|
||||||
expect(out).toContain('<citation indices="2,3"></citation>');
|
|
||||||
expect(out).toContain('```\n[9]\n```');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render Sources list card', () => {
|
|
||||||
renderCard({
|
|
||||||
message: 'Answer with [1]',
|
|
||||||
user_created: false,
|
|
||||||
citations: [
|
|
||||||
{
|
|
||||||
index: 1,
|
|
||||||
title: 'Example Source',
|
|
||||||
url: 'https://example.com/a',
|
|
||||||
published_at: '2026-07-03',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Source 1' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens citation URL in a new tab when inline marker clicked', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
|
|
||||||
renderCard({
|
|
||||||
message: 'See [1]',
|
|
||||||
user_created: false,
|
|
||||||
citations: [
|
|
||||||
{ index: 1, title: 'Src', url: 'https://example.com', published_at: null },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Source 1' }));
|
|
||||||
expect(openSpy).toHaveBeenCalledWith(
|
|
||||||
'https://example.com',
|
|
||||||
'_blank',
|
|
||||||
'noopener,noreferrer',
|
|
||||||
);
|
|
||||||
openSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+3
-106
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { ThemeProvider } from 'styled-components';
|
import { ThemeProvider } from 'styled-components';
|
||||||
import ConversationDetailCard from './ConversationDetailCard';
|
import ConversationDetailCard from './ConversationDetailCard';
|
||||||
|
|
||||||
const darkTheme = {
|
const theme = {
|
||||||
main: '#336699',
|
main: '#336699',
|
||||||
focus: '#224466',
|
focus: '#224466',
|
||||||
darkMode: true,
|
darkMode: true,
|
||||||
@@ -15,30 +14,14 @@ const darkTheme = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const lightTheme = {
|
const renderCard = (props: {
|
||||||
main: '#336699',
|
|
||||||
focus: '#224466',
|
|
||||||
darkMode: false,
|
|
||||||
colors: {
|
|
||||||
text: '#111111',
|
|
||||||
cardBackground: 'rgba(255,255,255,0.8)',
|
|
||||||
cardBorder: 'rgba(0,0,0,0.1)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCard = (
|
|
||||||
props: {
|
|
||||||
message: string;
|
message: string;
|
||||||
user_created: boolean;
|
user_created: boolean;
|
||||||
},
|
}) =>
|
||||||
theme: typeof darkTheme = darkTheme
|
|
||||||
) =>
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
|
||||||
<ThemeProvider theme={theme as never}>
|
<ThemeProvider theme={theme as never}>
|
||||||
<ConversationDetailCard {...props} />
|
<ConversationDetailCard {...props} />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</MemoryRouter>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('ConversationDetailCard', () => {
|
describe('ConversationDetailCard', () => {
|
||||||
@@ -60,90 +43,4 @@ describe('ConversationDetailCard', () => {
|
|||||||
expect(screen.getByText('Hi')).toBeInTheDocument();
|
expect(screen.getByText('Hi')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('message-token-usage')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('message-token-usage')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a friendly upgrade bubble for RAG feature_not_allowed errors (#85)', () => {
|
|
||||||
renderCard({
|
|
||||||
message: JSON.stringify({
|
|
||||||
type: 'error',
|
|
||||||
code: 'feature_not_allowed',
|
|
||||||
content: 'Your plan does not include rag document search.',
|
|
||||||
details: { feature: 'rag' },
|
|
||||||
}),
|
|
||||||
user_created: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByText('Your plan does not include rag document search.')
|
|
||||||
).toBeInTheDocument();
|
|
||||||
const upgradeLink = screen.getByRole('link', { name: /upgrade your plan/i });
|
|
||||||
expect(upgradeLink).toBeInTheDocument();
|
|
||||||
expect(upgradeLink).toHaveAttribute('href', '/account/');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders unrelated feature_not_allowed errors as a normal inline error, not the upgrade bubble', () => {
|
|
||||||
renderCard({
|
|
||||||
message: JSON.stringify({
|
|
||||||
type: 'error',
|
|
||||||
code: 'feature_not_allowed',
|
|
||||||
content: 'Your plan does not include image generation.',
|
|
||||||
details: { feature: 'image_generation' },
|
|
||||||
}),
|
|
||||||
user_created: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/image generation/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses theme text color for agent bubble in light mode (#88)', () => {
|
|
||||||
renderCard(
|
|
||||||
{ message: 'Light mode reply', user_created: false },
|
|
||||||
lightTheme
|
|
||||||
);
|
|
||||||
const message = screen.getByText('Light mode reply');
|
|
||||||
const agentBubble = message.closest('div');
|
|
||||||
expect(agentBubble).not.toBeNull();
|
|
||||||
expect(getComputedStyle(agentBubble!).color).toBe('rgb(17, 17, 17)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps light agent text in dark mode (#88)', () => {
|
|
||||||
renderCard(
|
|
||||||
{ message: 'Dark mode reply', user_created: false },
|
|
||||||
darkTheme
|
|
||||||
);
|
|
||||||
const message = screen.getByText('Dark mode reply');
|
|
||||||
const agentBubble = message.closest('div');
|
|
||||||
expect(agentBubble).not.toBeNull();
|
|
||||||
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,6 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React from "react";
|
||||||
import Markdown from 'markdown-to-jsx';
|
import Markdown from "markdown-to-jsx";
|
||||||
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 type { ActivityHistoryEntry, Citation } from '../../utils/wsFrames';
|
|
||||||
import type { PromptRating } from '../../utils/promptFeedback';
|
|
||||||
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
|
|
||||||
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); }
|
||||||
@@ -17,16 +10,10 @@ const fadeIn = keyframes`
|
|||||||
const MessageContainer = styled.div<{ $isUser: boolean }>`
|
const MessageContainer = styled.div<{ $isUser: boolean }>`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: ${(props) => (props.$isUser ? 'flex-end' : 'flex-start')};
|
align-items: ${(props) => (props.$isUser ? "flex-end" : "flex-start")};
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
animation: ${fadeIn} 0.3s ease-out;
|
animation: ${fadeIn} 0.3s ease-out;
|
||||||
|
|
||||||
&:hover [data-message-actions],
|
|
||||||
&:focus-within [data-message-actions] {
|
|
||||||
opacity: 1 !important;
|
|
||||||
pointer-events: auto !important;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Bubble = styled.div<{ $isUser: boolean }>`
|
const Bubble = styled.div<{ $isUser: boolean }>`
|
||||||
@@ -39,33 +26,19 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
|||||||
props.$isUser
|
props.$isUser
|
||||||
? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)`
|
? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)`
|
||||||
: props.theme.darkMode
|
: props.theme.darkMode
|
||||||
? 'rgba(255, 255, 255, 0.1)'
|
? "rgba(255, 255, 255, 0.1)"
|
||||||
: 'rgba(0, 0, 0, 0.06)'};
|
: "rgba(0, 0, 0, 0.7)"};
|
||||||
color: ${(props) =>
|
color: #fff;
|
||||||
props.$isUser || props.theme.darkMode ? '#fff' : props.theme.colors.text};
|
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid ${(props) =>
|
border: 1px solid ${(props) => props.theme.darkMode ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"};
|
||||||
props.$isUser
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
? props.theme.darkMode
|
|
||||||
? 'rgba(255, 255, 255, 0.1)'
|
|
||||||
: 'rgba(0, 0, 0, 0.1)'
|
|
||||||
: props.theme.darkMode
|
|
||||||
? 'rgba(255, 255, 255, 0.1)'
|
|
||||||
: 'rgba(0, 0, 0, 0.08)'};
|
|
||||||
box-shadow: ${(props) =>
|
|
||||||
props.$isUser || props.theme.darkMode
|
|
||||||
? '0 4px 15px rgba(0, 0, 0, 0.2)'
|
|
||||||
: '0 2px 10px rgba(0, 0, 0, 0.08)'};
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
border-bottom-right-radius: ${(props) => (props.$isUser ? '0.2rem' : '1.2rem')};
|
border-bottom-right-radius: ${(props) => (props.$isUser ? "0.2rem" : "1.2rem")};
|
||||||
border-bottom-left-radius: ${(props) => (props.$isUser ? '1.2rem' : '0.2rem')};
|
border-bottom-left-radius: ${(props) => (props.$isUser ? "1.2rem" : "0.2rem")};
|
||||||
|
|
||||||
& pre {
|
& pre {
|
||||||
background: ${(props) =>
|
background: rgba(0, 0, 0, 0.3);
|
||||||
props.$isUser || props.theme.darkMode
|
|
||||||
? 'rgba(0, 0, 0, 0.3)'
|
|
||||||
: 'rgba(0, 0, 0, 0.06)'};
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -78,8 +51,7 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
|||||||
}
|
}
|
||||||
|
|
||||||
& a {
|
& a {
|
||||||
color: ${(props) =>
|
color: #a0c4ff;
|
||||||
props.$isUser || props.theme.darkMode ? '#a0c4ff' : props.theme.main};
|
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,81 +67,33 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const UpgradeNotice = styled.div`
|
const LoadingDot = styled.div`
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #fff;
|
||||||
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
align-items: flex-start;
|
justify-content: center;
|
||||||
gap: 0.65rem;
|
padding: 0.5rem;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const UpgradeLink = styled(Link)`
|
|
||||||
background: ${(props) => props.theme.main};
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const CitationButton = styled.button`
|
|
||||||
display: inline;
|
|
||||||
margin: 0 0.1rem;
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background: ${({ theme }) =>
|
|
||||||
theme.darkMode ? 'rgba(160, 196, 255, 0.2)' : 'rgba(51, 102, 153, 0.12)'};
|
|
||||||
color: ${({ theme }) => (theme.darkMode ? '#a0c4ff' : theme.main)};
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
vertical-align: baseline;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid ${({ theme }) => theme.main};
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
/** Linkify [n] / [1, 2] outside fenced code blocks. */
|
|
||||||
export function injectCitationMarkers(markdown: string): string {
|
|
||||||
const parts = markdown.split(/(```[\s\S]*?```)/g);
|
|
||||||
return parts
|
|
||||||
.map((part) => {
|
|
||||||
if (part.startsWith('```')) return part;
|
|
||||||
return part.replace(
|
|
||||||
/\[(\d+(?:\s*,\s*\d+)*)\]/g,
|
|
||||||
(_match, nums: string) =>
|
|
||||||
`<citation indices="${nums.replace(/\s+/g, '')}"></citation>`,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConversationDetailCardProps = {
|
type ConversationDetailCardProps = {
|
||||||
message: string;
|
message: string;
|
||||||
user_created: boolean;
|
user_created: boolean;
|
||||||
promptId?: number;
|
|
||||||
citations?: Citation[];
|
|
||||||
isStreaming?: boolean;
|
|
||||||
isLast?: boolean;
|
|
||||||
conversationTitle?: string;
|
|
||||||
createdTimestamp?: Date | string | null;
|
|
||||||
initialRating?: PromptRating | null;
|
|
||||||
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 }) => {
|
||||||
@@ -177,7 +101,7 @@ const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
|||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
style={{ maxWidth: '100%', height: 'auto', borderRadius: '8px', marginTop: '10px' }}
|
style={{ maxWidth: "100%", height: "auto", borderRadius: "8px", marginTop: "10px" }}
|
||||||
alt="plot"
|
alt="plot"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -185,7 +109,7 @@ const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
|||||||
|
|
||||||
const MyError = ({ content }: { content: string }) => {
|
const MyError = ({ content }: { content: string }) => {
|
||||||
return (
|
return (
|
||||||
<span style={{ color: '#ff6b6b', fontWeight: 'bold', display: 'block', marginTop: '0.5rem' }}>
|
<span style={{ color: "#ff6b6b", fontWeight: "bold", display: "block", marginTop: "0.5rem" }}>
|
||||||
Error: {content}
|
Error: {content}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -194,131 +118,51 @@ const MyError = ({ content }: { content: string }) => {
|
|||||||
const ConversationDetailCard = ({
|
const ConversationDetailCard = ({
|
||||||
message,
|
message,
|
||||||
user_created,
|
user_created,
|
||||||
promptId,
|
|
||||||
citations = [],
|
|
||||||
isStreaming = false,
|
|
||||||
isLast = false,
|
|
||||||
conversationTitle = 'conversation',
|
|
||||||
createdTimestamp = null,
|
|
||||||
initialRating = null,
|
|
||||||
onRatingChange,
|
|
||||||
activityStage = null,
|
|
||||||
activityLabel = null,
|
|
||||||
activityDetail = null,
|
|
||||||
activityHistory = [],
|
|
||||||
activityInterrupted = false,
|
|
||||||
}: ConversationDetailCardProps): JSX.Element => {
|
}: ConversationDetailCardProps): JSX.Element => {
|
||||||
const CitationMark = useCallback(
|
|
||||||
({ indices }: { indices?: string }) => {
|
|
||||||
const list = (indices || '')
|
|
||||||
.split(',')
|
|
||||||
.map((n) => Number(n.trim()))
|
|
||||||
.filter((n) => !Number.isNaN(n));
|
|
||||||
if (!list.length) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{list.map((index, i) => {
|
|
||||||
const citation = citations.find((c) => c.index === index);
|
|
||||||
return (
|
|
||||||
<CitationButton
|
|
||||||
key={`${index}-${i}`}
|
|
||||||
type="button"
|
|
||||||
aria-label={`Source ${index}`}
|
|
||||||
title={citation?.title || `Source ${index}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (citation?.url) {
|
|
||||||
window.open(citation.url, '_blank', 'noopener,noreferrer');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
[{index}]
|
|
||||||
</CitationButton>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[citations],
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayMarkdown = useMemo(() => {
|
|
||||||
if (!message) return message;
|
|
||||||
if (!citations.length || user_created) return message;
|
|
||||||
return injectCitationMarkers(message);
|
|
||||||
}, [message, citations, user_created]);
|
|
||||||
|
|
||||||
if (message.length === 0) {
|
if (message.length === 0) {
|
||||||
return (
|
return (
|
||||||
<MessageContainer $isUser={false}>
|
<MessageContainer $isUser={false}>
|
||||||
<Bubble $isUser={false}>
|
<Bubble $isUser={false}>
|
||||||
<ActivityIndicator
|
<LoadingContainer>
|
||||||
stage={activityStage}
|
<LoadingDot />
|
||||||
label={activityLabel}
|
<LoadingDot />
|
||||||
detail={activityDetail}
|
<LoadingDot />
|
||||||
history={activityHistory}
|
</LoadingContainer>
|
||||||
interrupted={activityInterrupted}
|
|
||||||
/>
|
|
||||||
</Bubble>
|
</Bubble>
|
||||||
</MessageContainer>
|
</MessageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorPayload = parseChatErrorPayload(message);
|
let contentToAdd = message;
|
||||||
if (errorPayload && isRagFeatureNotAllowed(errorPayload)) {
|
|
||||||
return (
|
|
||||||
<MessageContainer $isUser={false}>
|
|
||||||
<Bubble $isUser={false}>
|
|
||||||
<UpgradeNotice>
|
|
||||||
<span>
|
|
||||||
{errorPayload.content ||
|
|
||||||
"Document search (RAG) isn't included in your current plan."}
|
|
||||||
</span>
|
|
||||||
<UpgradeLink to="/account/">Upgrade your plan</UpgradeLink>
|
|
||||||
</UpgradeNotice>
|
|
||||||
</Bubble>
|
|
||||||
</MessageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let contentToAdd = displayMarkdown;
|
|
||||||
let rawForCopy = message;
|
|
||||||
try {
|
try {
|
||||||
const parsedMessage = JSON.parse(message);
|
const parsedMessage = JSON.parse(message);
|
||||||
if (
|
if (
|
||||||
parsedMessage &&
|
parsedMessage &&
|
||||||
typeof parsedMessage === 'object' &&
|
typeof parsedMessage === "object" &&
|
||||||
parsedMessage.type
|
parsedMessage.type
|
||||||
) {
|
) {
|
||||||
switch (parsedMessage.type) {
|
switch (parsedMessage.type) {
|
||||||
case 'text':
|
case "text":
|
||||||
rawForCopy = parsedMessage.content;
|
contentToAdd = parsedMessage.content;
|
||||||
contentToAdd =
|
|
||||||
citations.length && !user_created
|
|
||||||
? injectCitationMarkers(parsedMessage.content)
|
|
||||||
: parsedMessage.content;
|
|
||||||
break;
|
break;
|
||||||
case 'plot':
|
case "plot":
|
||||||
contentToAdd = `<plot format="${parsedMessage.format}" image="${parsedMessage.image}"></plot>`;
|
contentToAdd = `<plot format="${parsedMessage.format}" image="${parsedMessage.image}"></plot>`;
|
||||||
rawForCopy = message;
|
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case "error":
|
||||||
contentToAdd = `<error content="${parsedMessage.content}"></error>`;
|
contentToAdd = `<error content="${parsedMessage.content}"></error>`;
|
||||||
rawForCopy = message;
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch { }
|
||||||
/* plain markdown */
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageContainer $isUser={user_created}>
|
<MessageContainer $isUser={user_created}>
|
||||||
<Bubble $isUser={user_created}>
|
<Bubble $isUser={user_created}>
|
||||||
<Markdown
|
<Markdown
|
||||||
className="display-linebreak"
|
className="display-linebreak"
|
||||||
style={{ whiteSpace: 'pre-line' }}
|
style={{ whiteSpace: "pre-line" }}
|
||||||
options={{
|
options={{
|
||||||
overrides: {
|
overrides: {
|
||||||
plot: {
|
plot: {
|
||||||
@@ -327,31 +171,12 @@ const ConversationDetailCard = ({
|
|||||||
error: {
|
error: {
|
||||||
component: MyError,
|
component: MyError,
|
||||||
},
|
},
|
||||||
pre: {
|
|
||||||
component: CustomPreBlock,
|
|
||||||
},
|
|
||||||
citation: {
|
|
||||||
component: CitationMark,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{contentToAdd}
|
{contentToAdd}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</Bubble>
|
</Bubble>
|
||||||
|
|
||||||
<MessageActions
|
|
||||||
promptId={promptId}
|
|
||||||
rawMarkdown={rawForCopy}
|
|
||||||
userCreated={user_created}
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
isLast={isLast}
|
|
||||||
conversationTitle={conversationTitle}
|
|
||||||
createdTimestamp={createdTimestamp}
|
|
||||||
citations={citations}
|
|
||||||
initialRating={initialRating}
|
|
||||||
onRatingChange={onRatingChange}
|
|
||||||
/>
|
|
||||||
</MessageContainer>
|
</MessageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,72 +1,25 @@
|
|||||||
import React, { useState } from 'react';
|
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
import styled from 'styled-components';
|
import tsx from "react-syntax-highlighter/dist/cjs/languages/prism/tsx";
|
||||||
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||||
import tsx from 'react-syntax-highlighter/dist/cjs/languages/prism/tsx';
|
|
||||||
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
|
||||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
|
||||||
import Check from '@mui/icons-material/Check';
|
|
||||||
import { IconButton, Tooltip } from '@mui/material';
|
|
||||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
|
||||||
import { showToast } from '../../utils/toastBus';
|
|
||||||
|
|
||||||
SyntaxHighlighter.registerLanguage('tsx', tsx);
|
SyntaxHighlighter.registerLanguage("tsx", tsx);
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
type CustomCodeBlock = {
|
||||||
position: relative;
|
children: string,
|
||||||
max-width: 100%;
|
className: string
|
||||||
`;
|
|
||||||
|
|
||||||
const CopyBtn = styled(IconButton)`
|
}
|
||||||
&& {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.35rem;
|
|
||||||
right: 0.35rem;
|
|
||||||
z-index: 1;
|
|
||||||
color: #fff;
|
|
||||||
background: rgba(0, 0, 0, 0.35);
|
|
||||||
padding: 0.25rem;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
type CustomCodeBlockProps = {
|
|
||||||
children: string;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CustomCodeBlock = ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: CustomCodeBlockProps): JSX.Element => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const language = className?.replace('lang-', '').replace('language-', '') || undefined;
|
|
||||||
const code = typeof children === 'string' ? children : String(children ?? '');
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await copyTextToClipboard(code.replace(/\n$/, ''));
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch {
|
|
||||||
showToast('Could not copy code', 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const CustomCodeBlock = ({children, className}: CustomCodeBlock): JSX.Element => {
|
||||||
|
const language = className?.replace("lang-","");
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
|
||||||
<Tooltip title={copied ? 'Copied' : 'Copy code'}>
|
|
||||||
<CopyBtn aria-label="Copy code block" size="small" onClick={() => void handleCopy()}>
|
|
||||||
{copied ? <Check fontSize="inherit" /> : <ContentCopy fontSize="inherit" />}
|
|
||||||
</CopyBtn>
|
|
||||||
</Tooltip>
|
|
||||||
<SyntaxHighlighter language={language} style={oneDark}>
|
<SyntaxHighlighter language={language} style={oneDark}>
|
||||||
{code}
|
{children}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
</Wrapper>
|
)
|
||||||
);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomCodeBlock;
|
export default CustomCodeBlock;
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
import CustomCodeBlock from '../CustomCodeBlock/CustomCodeBlock';
|
import CustomCodeBlock from "../CustomCodeBlock/CustomCodeBlock"
|
||||||
|
|
||||||
type CustomPreBlockProps = {
|
type CustomPreBlockProps = {
|
||||||
children: JSX.Element | JSX.Element[];
|
children: JSX.Element | JSX.Element[]
|
||||||
};
|
}
|
||||||
|
|
||||||
const CustomPreBlock = ({ children, ...rest }: CustomPreBlockProps): JSX.Element => {
|
const CustomPreBlock = ({children, ...rest}: CustomPreBlockProps): JSX.Element => {
|
||||||
const child = Array.isArray(children) ? children[0] : children;
|
if ("type" in children && children["type"] === "code") {
|
||||||
if (child && typeof child === 'object' && 'type' in child && child.type === 'code') {
|
return CustomCodeBlock({children: children["props"]["children"], className: children["props"]["className"] });
|
||||||
return (
|
|
||||||
<CustomCodeBlock
|
|
||||||
className={(child.props as { className?: string }).className}
|
|
||||||
>
|
|
||||||
{(child.props as { children?: string }).children as string}
|
|
||||||
</CustomCodeBlock>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <pre {...rest}>{children}</pre>;
|
return <pre {...rest}>{children}</pre>
|
||||||
};
|
}
|
||||||
|
|
||||||
export default CustomPreBlock;
|
export default CustomPreBlock;
|
||||||
@@ -39,8 +39,6 @@ const DashboardWrapperLayout = ({ children }: DashboardWrapperLayoutProps): JSX.
|
|||||||
const [onMouseEnter, setOnMouseEnter] = useState(false);
|
const [onMouseEnter, setOnMouseEnter] = useState(false);
|
||||||
const [rtlCache, setRtlCache] = useState<EmotionCache | null>(null);
|
const [rtlCache, setRtlCache] = useState<EmotionCache | null>(null);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const brandName =
|
|
||||||
process.env.REACT_APP_DEPLOY_ENV === "beta" ? "Beta Hesychia" : "Hesychia";
|
|
||||||
|
|
||||||
// Cache for the rtl
|
// Cache for the rtl
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
@@ -117,7 +115,7 @@ const DashboardWrapperLayout = ({ children }: DashboardWrapperLayoutProps): JSX.
|
|||||||
<Sidenav
|
<Sidenav
|
||||||
color={sidenavColor}
|
color={sidenavColor}
|
||||||
brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite}
|
brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite}
|
||||||
brandName={brandName}
|
brandName="Hesychia"
|
||||||
routes={[]} // {routes}
|
routes={[]} // {routes}
|
||||||
onMouseEnter={handleOnMouseEnter}
|
onMouseEnter={handleOnMouseEnter}
|
||||||
onMouseLeave={handleOnMouseLeave}
|
onMouseLeave={handleOnMouseLeave}
|
||||||
@@ -138,7 +136,7 @@ const DashboardWrapperLayout = ({ children }: DashboardWrapperLayoutProps): JSX.
|
|||||||
<Sidenav
|
<Sidenav
|
||||||
color={sidenavColor}
|
color={sidenavColor}
|
||||||
brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite}
|
brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite}
|
||||||
brandName={brandName}
|
brandName="Hesychia"
|
||||||
routes={[]} // {routes}
|
routes={[]} // {routes}
|
||||||
onMouseEnter={handleOnMouseEnter}
|
onMouseEnter={handleOnMouseEnter}
|
||||||
onMouseLeave={handleOnMouseLeave}
|
onMouseLeave={handleOnMouseLeave}
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { ThemeProvider } from 'styled-components';
|
|
||||||
import { AccountContext } from '../../contexts/AccountContext';
|
|
||||||
import { AuthContext } from '../../contexts/AuthContext';
|
|
||||||
import DeleteAccountSection from './DeleteAccountSection';
|
|
||||||
|
|
||||||
const mockDelete = jest.fn();
|
|
||||||
const mockPost = jest.fn();
|
|
||||||
const mockClearTokens = jest.fn();
|
|
||||||
const mockGetRefreshToken = jest.fn();
|
|
||||||
const mockApplyAccessToken = jest.fn();
|
|
||||||
const mockNavigate = jest.fn();
|
|
||||||
const mockSetAccount = jest.fn();
|
|
||||||
const mockSetAuthentication = jest.fn();
|
|
||||||
const mockTrackEvent = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('../../../axiosApi', () => ({
|
|
||||||
axiosInstance: {
|
|
||||||
delete: (...args: unknown[]) => mockDelete(...args),
|
|
||||||
post: (...args: unknown[]) => mockPost(...args),
|
|
||||||
},
|
|
||||||
applyAccessToken: (...args: unknown[]) => mockApplyAccessToken(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../../auth/tokenStorage', () => ({
|
|
||||||
clearTokens: (...args: unknown[]) => mockClearTokens(...args),
|
|
||||||
getRefreshToken: (...args: unknown[]) => mockGetRefreshToken(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
|
||||||
...jest.requireActual('react-router-dom'),
|
|
||||||
useNavigate: () => mockNavigate,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../../utils/analytics', () => ({
|
|
||||||
AnalyticsEvents: {
|
|
||||||
ACCOUNT_DELETE_STARTED: 'Account Delete Started',
|
|
||||||
ACCOUNT_DELETE_SUCCESS: 'Account Delete Success',
|
|
||||||
ACCOUNT_DELETE_FAILED: 'Account Delete Failed',
|
|
||||||
},
|
|
||||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../../utils/revenueCat', () => ({
|
|
||||||
logOutRevenueCat: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const theme = {
|
|
||||||
main: '#4a90e2',
|
|
||||||
darkMode: true,
|
|
||||||
colors: {
|
|
||||||
text: '#ffffff',
|
|
||||||
cardBackground: 'rgba(0,0,0,0.4)',
|
|
||||||
cardBorder: 'rgba(255,255,255,0.1)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSection = () =>
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{
|
|
||||||
authenticated: true,
|
|
||||||
setAuthentication: mockSetAuthentication,
|
|
||||||
needsNewPassword: false,
|
|
||||||
setNeedsNewPassword: () => {},
|
|
||||||
loading: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccountContext.Provider
|
|
||||||
value={{
|
|
||||||
account: { email: 'user@example.com' } as never,
|
|
||||||
setAccount: mockSetAccount,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteAccountSection />
|
|
||||||
</AccountContext.Provider>
|
|
||||||
</AuthContext.Provider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('DeleteAccountSection', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDelete.mockReset();
|
|
||||||
mockPost.mockReset();
|
|
||||||
mockClearTokens.mockReset();
|
|
||||||
mockGetRefreshToken.mockReset();
|
|
||||||
mockApplyAccessToken.mockReset();
|
|
||||||
mockNavigate.mockReset();
|
|
||||||
mockSetAccount.mockReset();
|
|
||||||
mockSetAuthentication.mockReset();
|
|
||||||
mockTrackEvent.mockReset();
|
|
||||||
mockGetRefreshToken.mockReturnValue('refresh-token');
|
|
||||||
mockClearTokens.mockResolvedValue(undefined);
|
|
||||||
mockDelete.mockResolvedValue({ data: { deleted: true } });
|
|
||||||
mockPost.mockResolvedValue({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires email confirmation then deletes and signs out', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderSection();
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /Delete my account/i }));
|
|
||||||
expect(await screen.findByTestId('delete-account-modal')).toBeInTheDocument();
|
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/Confirm email/i), 'user@example.com');
|
|
||||||
await user.click(screen.getByRole('button', { name: /^Delete account$/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockDelete).toHaveBeenCalledWith('/user/', {
|
|
||||||
data: { refresh_token: 'refresh-token' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(mockTrackEvent).toHaveBeenCalledWith('Account Delete Started');
|
|
||||||
expect(mockTrackEvent).toHaveBeenCalledWith('Account Delete Success');
|
|
||||||
expect(mockClearTokens).toHaveBeenCalled();
|
|
||||||
expect(mockApplyAccessToken).toHaveBeenCalledWith(null);
|
|
||||||
expect(mockSetAuthentication).toHaveBeenCalledWith(false);
|
|
||||||
expect(mockNavigate).toHaveBeenCalledWith('/signin/');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
import React, { useContext, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
|
||||||
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
|
||||||
import { AccountContext } from '../../contexts/AccountContext';
|
|
||||||
import { AuthContext } from '../../contexts/AuthContext';
|
|
||||||
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
|
||||||
import { logOutRevenueCat } from '../../utils/revenueCat';
|
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 2rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const CardTitle = styled.h2`
|
|
||||||
font-size: 1.8rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const BodyText = styled.p`
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.75;
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ErrorText = styled.p`
|
|
||||||
color: #ff6b6b;
|
|
||||||
margin: 0.75rem 0 0 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ButtonRow = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DangerButton = styled.button`
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #ff6b6b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: #ff6b6b;
|
|
||||||
padding: 0.8rem 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #ff6b6b22;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SecondaryButton = styled.button`
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
padding: 0.8rem 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConfirmInput = styled.input`
|
|
||||||
width: 100%;
|
|
||||||
max-width: 420px;
|
|
||||||
background: ${({ theme }) =>
|
|
||||||
theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.8rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
font-size: 1rem;
|
|
||||||
outline: none;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #ff6b6b;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ModalBackdrop = styled.div`
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 1.5rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ModalCard = styled.div`
|
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 1.75rem;
|
|
||||||
max-width: 520px;
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ModalTitle = styled.h3`
|
|
||||||
margin: 0 0 0.75rem 0;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
font-size: 1.35rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiErrorMessage(error: unknown, fallback: string): string {
|
|
||||||
const axiosError = error as {
|
|
||||||
response?: { data?: { detail?: string } };
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeleteAccountSection = (): JSX.Element => {
|
|
||||||
const { account, setAccount } = useContext(AccountContext);
|
|
||||||
const { setAuthentication } = useContext(AuthContext);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
||||||
const [confirmEmail, setConfirmEmail] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const email = account?.email || '';
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
if (loading) return;
|
|
||||||
setConfirmOpen(false);
|
|
||||||
setConfirmEmail('');
|
|
||||||
setError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!email || confirmEmail.trim().toLowerCase() !== email.toLowerCase()) {
|
|
||||||
setError('Type your account email exactly to confirm.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_STARTED);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const refreshToken = getRefreshToken();
|
|
||||||
await axiosInstance.delete('/user/', {
|
|
||||||
data: refreshToken ? { refresh_token: refreshToken } : {},
|
|
||||||
});
|
|
||||||
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_SUCCESS);
|
|
||||||
try {
|
|
||||||
if (refreshToken) {
|
|
||||||
await axiosInstance.post('blacklist/', { refresh_token: refreshToken });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Account already deleted; local cleanup is enough.
|
|
||||||
}
|
|
||||||
await clearTokens();
|
|
||||||
applyAccessToken(null);
|
|
||||||
setAuthentication(false);
|
|
||||||
setAccount(undefined);
|
|
||||||
void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err));
|
|
||||||
navigate('/signin/');
|
|
||||||
} catch (err: unknown) {
|
|
||||||
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED);
|
|
||||||
setError(apiErrorMessage(err, 'Could not delete account. Try again.'));
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<GlassCard data-testid="delete-account-section">
|
|
||||||
<CardTitle>Delete account</CardTitle>
|
|
||||||
<BodyText>
|
|
||||||
Permanently deactivate your account. Conversations are hidden and you will
|
|
||||||
be signed out. This cannot be undone from the app.
|
|
||||||
</BodyText>
|
|
||||||
<DangerButton type="button" onClick={() => setConfirmOpen(true)}>
|
|
||||||
Delete my account
|
|
||||||
</DangerButton>
|
|
||||||
</GlassCard>
|
|
||||||
|
|
||||||
{confirmOpen ? (
|
|
||||||
<ModalBackdrop
|
|
||||||
role="presentation"
|
|
||||||
onClick={closeModal}
|
|
||||||
data-testid="delete-account-modal"
|
|
||||||
>
|
|
||||||
<ModalCard
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="delete-account-title"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ModalTitle id="delete-account-title">Delete your account?</ModalTitle>
|
|
||||||
<BodyText>
|
|
||||||
Type <strong>{email || 'your email'}</strong> to confirm. You will lose
|
|
||||||
access immediately after deletion.
|
|
||||||
</BodyText>
|
|
||||||
<ConfirmInput
|
|
||||||
type="email"
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Confirm email"
|
|
||||||
value={confirmEmail}
|
|
||||||
onChange={(event) => setConfirmEmail(event.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
aria-label="Confirm email"
|
|
||||||
/>
|
|
||||||
{error ? <ErrorText role="alert">{error}</ErrorText> : null}
|
|
||||||
<ButtonRow>
|
|
||||||
<DangerButton
|
|
||||||
type="button"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={loading || !confirmEmail}
|
|
||||||
>
|
|
||||||
{loading ? 'Deleting…' : 'Delete account'}
|
|
||||||
</DangerButton>
|
|
||||||
<SecondaryButton type="button" onClick={closeModal} disabled={loading}>
|
|
||||||
Keep account
|
|
||||||
</SecondaryButton>
|
|
||||||
</ButtonRow>
|
|
||||||
</ModalCard>
|
|
||||||
</ModalBackdrop>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DeleteAccountSection;
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import {
|
|
||||||
DriveConnectIntent,
|
|
||||||
DriveConnectionKind,
|
|
||||||
DriveConnectionType,
|
|
||||||
DriveProvider,
|
|
||||||
connectDrive,
|
|
||||||
disconnectDriveConnection,
|
|
||||||
driveSyncProgressPercent,
|
|
||||||
fetchDriveConnections,
|
|
||||||
formatDriveSyncError,
|
|
||||||
parseResourceIdsInput,
|
|
||||||
saveDriveResourceSelection,
|
|
||||||
syncDriveConnection,
|
|
||||||
waitForDriveSyncSettlement,
|
|
||||||
} from "../../utils/drive";
|
|
||||||
|
|
||||||
const Section = styled.div`
|
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 2rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SectionTitle = styled.h2`
|
|
||||||
font-size: 1.8rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SectionDescription = styled.p`
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.7;
|
|
||||||
margin: 0.75rem 0 1.5rem 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConnectButtonRow = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConnectButton = styled.button`
|
|
||||||
background: ${({ theme }) => theme.main};
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: #fff;
|
|
||||||
padding: 0.7rem 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px ${({ theme }) => theme.main}66;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConnectionList = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConnectionCard = styled.div`
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
padding: 1.25rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConnectionHeader = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConnectionTitle = styled.strong`
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
font-size: 1.05rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConnectionMeta = styled.p`
|
|
||||||
margin: 0.35rem 0 0 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.65;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConnectionActions = styled.div`
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SmallButton = styled.button`
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
padding: 0.45rem 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DangerButton = styled(SmallButton)`
|
|
||||||
color: #ff6b6b;
|
|
||||||
border-color: rgba(255, 107, 107, 0.4);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(255, 71, 87, 0.1);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ResourceForm = styled.div`
|
|
||||||
margin-top: 1rem;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ResourceInput = styled.input`
|
|
||||||
flex: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.4)'};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ResourceTagList = styled.div`
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ResourceTag = styled.span`
|
|
||||||
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.08)'};
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const EmptyState = styled.p`
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.6;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AlertBanner = styled.div<{ $tone: 'error' | 'success' }>`
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin: 0 0 1.25rem 0;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: 1px solid
|
|
||||||
${({ $tone }) => ($tone === 'error' ? 'rgba(255, 107, 107, 0.45)' : 'rgba(46, 204, 113, 0.45)')};
|
|
||||||
background: ${({ $tone }) =>
|
|
||||||
$tone === 'error' ? 'rgba(255, 71, 87, 0.12)' : 'rgba(46, 204, 113, 0.12)'};
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AlertDismiss = styled.button`
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.7;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SyncErrorText = styled.p`
|
|
||||||
margin: 0.75rem 0 0 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #ff6b6b;
|
|
||||||
line-height: 1.4;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProgressWrap = styled.div`
|
|
||||||
margin-top: 0.85rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProgressTrack = styled.div`
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: ${({ theme }) =>
|
|
||||||
theme.darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)'};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProgressFill = styled.div<{ $percent: number | null }>`
|
|
||||||
height: 100%;
|
|
||||||
width: ${({ $percent }) => ($percent == null ? '40%' : `${$percent}%`)};
|
|
||||||
border-radius: 999px;
|
|
||||||
background: ${({ theme }) => theme.main};
|
|
||||||
transition: width 0.25s ease;
|
|
||||||
${({ $percent }) =>
|
|
||||||
$percent == null
|
|
||||||
? `
|
|
||||||
animation: sync-indeterminate 1.2s ease-in-out infinite;
|
|
||||||
@keyframes sync-indeterminate {
|
|
||||||
0% { transform: translateX(-100%); }
|
|
||||||
100% { transform: translateX(250%); }
|
|
||||||
}
|
|
||||||
`
|
|
||||||
: ''}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProgressLabel = styled.p`
|
|
||||||
margin: 0.4rem 0 0 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.7;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<DriveProvider, string> = {
|
|
||||||
google: "Google Drive",
|
|
||||||
microsoft: "OneDrive",
|
|
||||||
};
|
|
||||||
|
|
||||||
type PendingAction = 'sync' | 'save' | 'disconnect';
|
|
||||||
|
|
||||||
type DriveConnectionsSectionProps = {
|
|
||||||
kind: DriveConnectionKind;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
connectIntent: DriveConnectIntent;
|
|
||||||
onSynced?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DriveConnectionsSection = ({
|
|
||||||
kind,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
connectIntent,
|
|
||||||
onSynced,
|
|
||||||
}: DriveConnectionsSectionProps): JSX.Element => {
|
|
||||||
const [connections, setConnections] = useState<DriveConnectionType[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [loadError, setLoadError] = useState(false);
|
|
||||||
const [connectError, setConnectError] = useState<string | null>(null);
|
|
||||||
const [syncBanner, setSyncBanner] = useState<{ tone: 'error' | 'success'; message: string } | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [resourceInputs, setResourceInputs] = useState<Record<number, string>>({});
|
|
||||||
const [pendingAction, setPendingAction] = useState<Record<number, PendingAction | undefined>>({});
|
|
||||||
|
|
||||||
const loadConnections = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setLoadError(false);
|
|
||||||
try {
|
|
||||||
const all = await fetchDriveConnections();
|
|
||||||
setConnections(all.filter((conn) => (conn.kind || 'personal') === kind));
|
|
||||||
} catch {
|
|
||||||
setLoadError(true);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [kind]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConnections();
|
|
||||||
}, [loadConnections]);
|
|
||||||
|
|
||||||
const setAction = (id: number, action?: PendingAction) => {
|
|
||||||
setPendingAction((prev) => ({ ...prev, [id]: action }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const upsertConnection = (updated: DriveConnectionType) => {
|
|
||||||
setConnections((prev) => {
|
|
||||||
const exists = prev.some((conn) => conn.id === updated.id);
|
|
||||||
if (!exists) {
|
|
||||||
return (updated.kind || 'personal') === kind ? [...prev, updated] : prev;
|
|
||||||
}
|
|
||||||
return prev.map((conn) => (conn.id === updated.id ? { ...conn, ...updated } : conn));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = async (id: number) => {
|
|
||||||
setAction(id, 'disconnect');
|
|
||||||
try {
|
|
||||||
await disconnectDriveConnection(id);
|
|
||||||
setConnections((prev) => prev.filter((conn) => conn.id !== id));
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
setSyncBanner({ tone: 'error', message: 'Could not disconnect this drive. Try again.' });
|
|
||||||
} finally {
|
|
||||||
setAction(id, undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSync = async (id: number) => {
|
|
||||||
setAction(id, 'sync');
|
|
||||||
setSyncBanner(null);
|
|
||||||
try {
|
|
||||||
const enqueued = await syncDriveConnection(id);
|
|
||||||
upsertConnection(enqueued.connection);
|
|
||||||
|
|
||||||
const settled =
|
|
||||||
enqueued.connection.last_sync_status === 'pending'
|
|
||||||
? await waitForDriveSyncSettlement(id, {
|
|
||||||
onProgress: (connection) => upsertConnection(connection),
|
|
||||||
})
|
|
||||||
: enqueued.connection;
|
|
||||||
|
|
||||||
upsertConnection(settled);
|
|
||||||
|
|
||||||
if (settled.last_sync_status === 'error') {
|
|
||||||
setSyncBanner({
|
|
||||||
tone: 'error',
|
|
||||||
message: formatDriveSyncError(settled.last_sync_error),
|
|
||||||
});
|
|
||||||
} else if (settled.last_sync_status === 'ok') {
|
|
||||||
setSyncBanner({ tone: 'success', message: 'Drive sync finished.' });
|
|
||||||
onSynced?.();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
const message =
|
|
||||||
err instanceof Error ? err.message : 'Drive sync failed. Try again.';
|
|
||||||
setSyncBanner({ tone: 'error', message });
|
|
||||||
await loadConnections();
|
|
||||||
} finally {
|
|
||||||
setAction(id, undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveResources = async (id: number) => {
|
|
||||||
const resourceIds = parseResourceIdsInput(resourceInputs[id] || '');
|
|
||||||
if (resourceIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAction(id, 'save');
|
|
||||||
try {
|
|
||||||
const updated = await saveDriveResourceSelection(id, {
|
|
||||||
resource_ids: resourceIds,
|
|
||||||
resource_labels: resourceIds,
|
|
||||||
});
|
|
||||||
setConnections((prev) => prev.map((conn) => (conn.id === id ? { ...conn, ...updated } : conn)));
|
|
||||||
setResourceInputs((prev) => ({ ...prev, [id]: '' }));
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
setSyncBanner({ tone: 'error', message: 'Could not save folder selection. Try again.' });
|
|
||||||
} finally {
|
|
||||||
setAction(id, undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section>
|
|
||||||
<SectionTitle>{title}</SectionTitle>
|
|
||||||
{description && <SectionDescription>{description}</SectionDescription>}
|
|
||||||
|
|
||||||
{syncBanner && (
|
|
||||||
<AlertBanner $tone={syncBanner.tone} role="alert">
|
|
||||||
<span>{syncBanner.message}</span>
|
|
||||||
<AlertDismiss
|
|
||||||
type="button"
|
|
||||||
aria-label="Dismiss"
|
|
||||||
onClick={() => setSyncBanner(null)}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</AlertDismiss>
|
|
||||||
</AlertBanner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ConnectButtonRow>
|
|
||||||
<ConnectButton
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setConnectError(null);
|
|
||||||
void connectDrive('google', connectIntent).catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
setConnectError('Could not start Google Drive connect. Try again.');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Connect Google Drive
|
|
||||||
</ConnectButton>
|
|
||||||
<ConnectButton
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setConnectError(null);
|
|
||||||
void connectDrive('microsoft', connectIntent).catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
setConnectError('Could not start OneDrive connect. Try again.');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Connect OneDrive
|
|
||||||
</ConnectButton>
|
|
||||||
</ConnectButtonRow>
|
|
||||||
|
|
||||||
{connectError && <EmptyState>{connectError}</EmptyState>}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<EmptyState>Loading connections…</EmptyState>
|
|
||||||
) : loadError ? (
|
|
||||||
<EmptyState>Could not load drive connections. Try again later.</EmptyState>
|
|
||||||
) : connections.length === 0 ? (
|
|
||||||
<EmptyState>No drives connected yet.</EmptyState>
|
|
||||||
) : (
|
|
||||||
<ConnectionList>
|
|
||||||
{connections.map((conn) => {
|
|
||||||
const action = pendingAction[conn.id];
|
|
||||||
const selectedLabels = conn.selected_resource_labels?.length
|
|
||||||
? conn.selected_resource_labels
|
|
||||||
: conn.selected_resource_ids || [];
|
|
||||||
const isSyncing = action === 'sync' || conn.last_sync_status === 'pending';
|
|
||||||
const progressPercent = isSyncing ? driveSyncProgressPercent(conn) : null;
|
|
||||||
const processed = conn.sync_processed ?? 0;
|
|
||||||
const total = conn.sync_total ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConnectionCard key={conn.id}>
|
|
||||||
<ConnectionHeader>
|
|
||||||
<div>
|
|
||||||
<ConnectionTitle>{PROVIDER_LABELS[conn.provider] || conn.provider}</ConnectionTitle>
|
|
||||||
<ConnectionMeta>
|
|
||||||
{conn.external_account_email || 'Connected account'}
|
|
||||||
{conn.last_sync_status ? ` · ${conn.last_sync_status}` : ''}
|
|
||||||
{conn.last_sync_at
|
|
||||||
? ` · Last synced ${new Date(conn.last_sync_at).toLocaleString()}`
|
|
||||||
: ''}
|
|
||||||
</ConnectionMeta>
|
|
||||||
</div>
|
|
||||||
<ConnectionActions>
|
|
||||||
<SmallButton
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSync(conn.id)}
|
|
||||||
disabled={Boolean(action) || conn.last_sync_status === 'pending'}
|
|
||||||
>
|
|
||||||
{isSyncing ? 'Syncing…' : 'Sync now'}
|
|
||||||
</SmallButton>
|
|
||||||
<DangerButton
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDisconnect(conn.id)}
|
|
||||||
disabled={Boolean(action)}
|
|
||||||
>
|
|
||||||
{action === 'disconnect' ? 'Disconnecting…' : 'Disconnect'}
|
|
||||||
</DangerButton>
|
|
||||||
</ConnectionActions>
|
|
||||||
</ConnectionHeader>
|
|
||||||
|
|
||||||
{isSyncing && (
|
|
||||||
<ProgressWrap aria-label="Drive sync progress">
|
|
||||||
<ProgressTrack>
|
|
||||||
<ProgressFill $percent={progressPercent} />
|
|
||||||
</ProgressTrack>
|
|
||||||
<ProgressLabel>
|
|
||||||
{progressPercent == null
|
|
||||||
? 'Discovering files…'
|
|
||||||
: `${processed} / ${total} files (${progressPercent}%)`}
|
|
||||||
</ProgressLabel>
|
|
||||||
</ProgressWrap>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{conn.last_sync_status === 'error' && conn.last_sync_error && (
|
|
||||||
<SyncErrorText>{formatDriveSyncError(conn.last_sync_error)}</SyncErrorText>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedLabels.length > 0 && (
|
|
||||||
<ResourceTagList>
|
|
||||||
{selectedLabels.map((label, idx) => (
|
|
||||||
<ResourceTag key={`${conn.id}-${idx}`}>{label}</ResourceTag>
|
|
||||||
))}
|
|
||||||
</ResourceTagList>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResourceForm>
|
|
||||||
<ResourceInput
|
|
||||||
type="text"
|
|
||||||
placeholder="Folder or file IDs, comma separated"
|
|
||||||
value={resourceInputs[conn.id] || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setResourceInputs((prev) => ({ ...prev, [conn.id]: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SmallButton
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSaveResources(conn.id)}
|
|
||||||
disabled={action === 'save' || !(resourceInputs[conn.id] || '').trim()}
|
|
||||||
>
|
|
||||||
{action === 'save' ? 'Saving…' : 'Save folders'}
|
|
||||||
</SmallButton>
|
|
||||||
</ResourceForm>
|
|
||||||
</ConnectionCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ConnectionList>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DriveConnectionsSection;
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { ThemeProvider } from 'styled-components';
|
|
||||||
import Header2 from './Header2';
|
|
||||||
import { AuthContext } from '../../contexts/AuthContext';
|
|
||||||
import { AccountContext } from '../../contexts/AccountContext';
|
|
||||||
import { resetSubscriptionCache } from '../../hooks/useSubscription';
|
|
||||||
|
|
||||||
const mockGet = jest.fn();
|
|
||||||
const mockPost = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('../../../axiosApi', () => ({
|
|
||||||
axiosInstance: {
|
|
||||||
get: (...args: unknown[]) => mockGet(...args),
|
|
||||||
post: (...args: unknown[]) => mockPost(...args),
|
|
||||||
defaults: { headers: { common: {} as Record<string, string | null> } },
|
|
||||||
},
|
|
||||||
applyAccessToken: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../../utils/revenueCat', () => ({
|
|
||||||
logOutRevenueCat: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const theme = {
|
|
||||||
main: '#4a90e2',
|
|
||||||
focus: '#224466',
|
|
||||||
darkMode: true,
|
|
||||||
colors: {
|
|
||||||
text: '#ffffff',
|
|
||||||
cardBorder: 'rgba(255,255,255,0.1)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderHeader = (props: { onOpenConversations?: () => void } = {}) =>
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ThemeProvider theme={theme as never}>
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{
|
|
||||||
authenticated: true,
|
|
||||||
setAuthentication: jest.fn(),
|
|
||||||
needsNewPassword: false,
|
|
||||||
setNeedsNewPassword: jest.fn(),
|
|
||||||
loading: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccountContext.Provider value={{ account: undefined, setAccount: jest.fn() }}>
|
|
||||||
<Header2 {...props} />
|
|
||||||
</AccountContext.Provider>
|
|
||||||
</AuthContext.Provider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
const subscriptionWithRag = (rag: boolean) => ({
|
|
||||||
data: {
|
|
||||||
plan: {
|
|
||||||
slug: rag ? 'pro' : 'standard',
|
|
||||||
name: rag ? 'Pro' : 'Standard',
|
|
||||||
features: {
|
|
||||||
text_generation: true,
|
|
||||||
image_generation: rag,
|
|
||||||
rag,
|
|
||||||
all_future_features: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 'active',
|
|
||||||
source: 'stripe',
|
|
||||||
needs_checkout: false,
|
|
||||||
stripe_subscription_id: 'sub_1',
|
|
||||||
usage: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Header2 (#81 subscription-aware Documents nav link)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockGet.mockReset();
|
|
||||||
mockPost.mockReset();
|
|
||||||
resetSubscriptionCache();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides the Documents link while the plan does not include RAG', async () => {
|
|
||||||
mockGet.mockResolvedValue(subscriptionWithRag(false));
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/monetization/subscription/'));
|
|
||||||
expect(screen.queryByText('Documents')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the Documents link once the plan includes RAG', async () => {
|
|
||||||
mockGet.mockResolvedValue(subscriptionWithRag(true));
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
|
|
||||||
// Desktop nav + mobile dropdown both render a "Documents" link.
|
|
||||||
expect(await screen.findAllByText('Documents')).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Header2 (#87 conversations toggle beside logo)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockGet.mockReset();
|
|
||||||
mockPost.mockReset();
|
|
||||||
resetSubscriptionCache();
|
|
||||||
mockGet.mockResolvedValue(subscriptionWithRag(false));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render conversations toggle when callback is omitted', () => {
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.queryByRole('button', { name: /open conversations/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders conversations toggle next to brand when callback is provided', () => {
|
|
||||||
const onOpenConversations = jest.fn();
|
|
||||||
renderHeader({ onOpenConversations });
|
|
||||||
|
|
||||||
// Mobile-only via CSS (display:none in jsdom desktop width); query by aria-label.
|
|
||||||
const toggle = screen.getByLabelText('Open conversations');
|
|
||||||
expect(toggle).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Hesychia')).toBeInTheDocument();
|
|
||||||
toggle.click();
|
|
||||||
expect(onOpenConversations).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,8 +5,6 @@ import { AuthContext } from '../../contexts/AuthContext';
|
|||||||
import { AccountContext } from '../../contexts/AccountContext';
|
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 { 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`
|
||||||
@@ -32,50 +30,6 @@ const HeaderContainer = styled.header`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const BrandCluster = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConversationsToggle = styled.button`
|
|
||||||
display: none;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.35rem 0.55rem;
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 0.65rem;
|
|
||||||
background: ${({ theme }) =>
|
|
||||||
theme.darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)'};
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: ${({ theme }) =>
|
|
||||||
theme.darkMode ? 'rgba(255, 255, 255, 0.14)' : 'rgba(0, 0, 0, 0.1)'};
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ConversationsToggleLabel = styled.span`
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
@media (max-width: 380px) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Logo = styled.div`
|
const Logo = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -96,8 +50,6 @@ const LogoWordmark = styled.h4`
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -186,14 +138,6 @@ const HamburgerIcon = ({ color }: { color: string }) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ConversationsMenuIcon = ({ color }: { color: string }) => (
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<path d="M3 12H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<path d="M3 6H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<path d="M3 18H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CloseIcon = ({ color }: { color: string }) => (
|
const CloseIcon = ({ color }: { color: string }) => (
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M18 6L6 18" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M18 6L6 18" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
@@ -205,22 +149,14 @@ type Header2Props = {
|
|||||||
absolute?: Boolean;
|
absolute?: Boolean;
|
||||||
light?: Boolean;
|
light?: Boolean;
|
||||||
isMini?: Boolean;
|
isMini?: Boolean;
|
||||||
/** Mobile: open conversations drawer. Rendered inline beside logo when set. */
|
|
||||||
onOpenConversations?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header2 = ({
|
const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Props): JSX.Element => {
|
||||||
absolute = false,
|
|
||||||
light = false,
|
|
||||||
isMini = false,
|
|
||||||
onOpenConversations,
|
|
||||||
}: Header2Props): JSX.Element => {
|
|
||||||
const { setAuthentication } = useContext(AuthContext);
|
const { setAuthentication } = useContext(AuthContext);
|
||||||
const { setAccount } = useContext(AccountContext);
|
const { setAccount } = useContext(AccountContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { hasRag } = useSubscription();
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -231,7 +167,6 @@ 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);
|
||||||
@@ -245,28 +180,16 @@ const Header2 = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderContainer>
|
<HeaderContainer>
|
||||||
<BrandCluster>
|
|
||||||
{onOpenConversations && (
|
|
||||||
<ConversationsToggle
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenConversations}
|
|
||||||
aria-label="Open conversations"
|
|
||||||
>
|
|
||||||
<ConversationsMenuIcon color={theme.colors.text} />
|
|
||||||
<ConversationsToggleLabel>Conversations</ConversationsToggleLabel>
|
|
||||||
</ConversationsToggle>
|
|
||||||
)}
|
|
||||||
<Logo onClick={() => navigate('/')}>
|
<Logo onClick={() => navigate('/')}>
|
||||||
<LogoMark src={hesychiaMark} alt="" />
|
<LogoMark src={hesychiaMark} alt="" />
|
||||||
<LogoWordmark>Hesychia</LogoWordmark>
|
<LogoWordmark>Hesychia</LogoWordmark>
|
||||||
</Logo>
|
</Logo>
|
||||||
</BrandCluster>
|
|
||||||
|
|
||||||
{/* Desktop Nav */}
|
{/* Desktop Nav */}
|
||||||
<Nav>
|
<Nav>
|
||||||
<NavLink onClick={() => navigate('/')}>Dashboard</NavLink>
|
<NavLink onClick={() => navigate('/')}>Dashboard</NavLink>
|
||||||
<NavLink onClick={() => navigate('/account/')}>Account</NavLink>
|
<NavLink onClick={() => navigate('/account/')}>Account</NavLink>
|
||||||
{hasRag && <NavLink onClick={() => navigate('/document_storage/')}>Documents</NavLink>}
|
<NavLink onClick={() => navigate('/document_storage/')}>Documents</NavLink>
|
||||||
<NavLink onClick={() => navigate('/analytics/')}>Analytics</NavLink>
|
<NavLink onClick={() => navigate('/analytics/')}>Analytics</NavLink>
|
||||||
<NavLink onClick={() => navigate('/feedback/')}>Feedback</NavLink>
|
<NavLink onClick={() => navigate('/feedback/')}>Feedback</NavLink>
|
||||||
<SignOutButton onClick={handleSignOut}>Sign Out</SignOutButton>
|
<SignOutButton onClick={handleSignOut}>Sign Out</SignOutButton>
|
||||||
@@ -281,7 +204,7 @@ const Header2 = ({
|
|||||||
<MobileMenuDropdown isOpen={isMenuOpen}>
|
<MobileMenuDropdown isOpen={isMenuOpen}>
|
||||||
<NavLink onClick={() => handleNavClick('/')}>Dashboard</NavLink>
|
<NavLink onClick={() => handleNavClick('/')}>Dashboard</NavLink>
|
||||||
<NavLink onClick={() => handleNavClick('/account/')}>Account</NavLink>
|
<NavLink onClick={() => handleNavClick('/account/')}>Account</NavLink>
|
||||||
{hasRag && <NavLink onClick={() => handleNavClick('/document_storage/')}>Documents</NavLink>}
|
<NavLink onClick={() => handleNavClick('/document_storage/')}>Documents</NavLink>
|
||||||
<NavLink onClick={() => handleNavClick('/analytics/')}>Analytics</NavLink>
|
<NavLink onClick={() => handleNavClick('/analytics/')}>Analytics</NavLink>
|
||||||
<NavLink onClick={() => handleNavClick('/feedback/')}>Feedback</NavLink>
|
<NavLink onClick={() => handleNavClick('/feedback/')}>Feedback</NavLink>
|
||||||
<SignOutButton onClick={() => { handleSignOut(); setIsMenuOpen(false); }}>Sign Out</SignOutButton>
|
<SignOutButton onClick={() => { handleSignOut(); setIsMenuOpen(false); }}>Sign Out</SignOutButton>
|
||||||
|
|||||||
@@ -1,361 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import {
|
|
||||||
IconButton,
|
|
||||||
Tooltip,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
Popover,
|
|
||||||
Chip,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
} from '@mui/material';
|
|
||||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
|
||||||
import Check from '@mui/icons-material/Check';
|
|
||||||
import ThumbUp from '@mui/icons-material/ThumbUp';
|
|
||||||
import ThumbUpOutlined from '@mui/icons-material/ThumbUpOutlined';
|
|
||||||
import ThumbDown from '@mui/icons-material/ThumbDown';
|
|
||||||
import ThumbDownOutlined from '@mui/icons-material/ThumbDownOutlined';
|
|
||||||
import FileDownload from '@mui/icons-material/FileDownload';
|
|
||||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
|
||||||
import { showToast } from '../../utils/toastBus';
|
|
||||||
import {
|
|
||||||
clearPromptFeedback,
|
|
||||||
upsertPromptFeedback,
|
|
||||||
type PromptRating,
|
|
||||||
} from '../../utils/promptFeedback';
|
|
||||||
import {
|
|
||||||
exportChat,
|
|
||||||
type ExportFormat,
|
|
||||||
type ExportTurn,
|
|
||||||
} from '../../utils/export/exportChat';
|
|
||||||
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
|
||||||
import type { Citation } from '../../utils/wsFrames';
|
|
||||||
|
|
||||||
const DOWN_REASONS: { code: string; label: string }[] = [
|
|
||||||
{ code: 'incorrect', label: 'Incorrect' },
|
|
||||||
{ code: 'out_of_date', label: 'Out of date' },
|
|
||||||
{ code: 'didnt_follow_instructions', label: "Didn't follow instructions" },
|
|
||||||
{ code: 'unsafe', label: 'Unsafe' },
|
|
||||||
{ code: 'other', label: 'Other' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ActionsRow = styled.div<{ $alwaysVisible: boolean }>`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.15rem;
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
min-height: 2rem;
|
|
||||||
max-width: 100%;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
opacity: ${({ $alwaysVisible }) => ($alwaysVisible ? 1 : 0)};
|
|
||||||
pointer-events: ${({ $alwaysVisible }) => ($alwaysVisible ? 'auto' : 'none')};
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
|
||||||
/* Desktop: revealed via parent :hover / :focus-within when not forced visible */
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: none), (pointer: coarse) {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ActionIconButton = styled(IconButton)`
|
|
||||||
&& {
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.7;
|
|
||||||
padding: 0.35rem;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus-visible {
|
|
||||||
opacity: 1;
|
|
||||||
background: ${({ theme }) =>
|
|
||||||
theme.darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Mui-disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
type MessageActionsProps = {
|
|
||||||
promptId?: number;
|
|
||||||
rawMarkdown: string;
|
|
||||||
userCreated: boolean;
|
|
||||||
isStreaming?: boolean;
|
|
||||||
isLast?: boolean;
|
|
||||||
conversationTitle: string;
|
|
||||||
createdTimestamp?: Date | string | null;
|
|
||||||
citations?: Citation[];
|
|
||||||
initialRating?: PromptRating | null;
|
|
||||||
onRatingChange?: (rating: PromptRating | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MessageActions = ({
|
|
||||||
promptId,
|
|
||||||
rawMarkdown,
|
|
||||||
userCreated,
|
|
||||||
isStreaming = false,
|
|
||||||
isLast = false,
|
|
||||||
conversationTitle,
|
|
||||||
createdTimestamp,
|
|
||||||
citations = [],
|
|
||||||
initialRating = null,
|
|
||||||
onRatingChange,
|
|
||||||
}: MessageActionsProps): JSX.Element | null => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [rating, setRating] = useState<PromptRating | null>(initialRating);
|
|
||||||
const [exportAnchor, setExportAnchor] = useState<null | HTMLElement>(null);
|
|
||||||
const [reasonAnchor, setReasonAnchor] = useState<null | HTMLElement>(null);
|
|
||||||
const [reasonCode, setReasonCode] = useState<string | null>(null);
|
|
||||||
const [reasonComment, setReasonComment] = useState('');
|
|
||||||
const [focused, setFocused] = useState(false);
|
|
||||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRating(initialRating);
|
|
||||||
}, [initialRating, promptId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isStreaming) return null;
|
|
||||||
|
|
||||||
const alwaysVisible = isLast || focused || Boolean(exportAnchor) || Boolean(reasonAnchor);
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await copyTextToClipboard(rawMarkdown);
|
|
||||||
setCopied(true);
|
|
||||||
trackEvent(AnalyticsEvents.MESSAGE_COPIED, {
|
|
||||||
role: userCreated ? 'user' : 'assistant',
|
|
||||||
});
|
|
||||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
|
||||||
copyTimer.current = setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch {
|
|
||||||
showToast('Could not copy message', 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyRating = async (next: PromptRating | null) => {
|
|
||||||
if (!promptId) {
|
|
||||||
showToast('Rating unavailable until message is saved', 'info');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const prev = rating;
|
|
||||||
setRating(next);
|
|
||||||
onRatingChange?.(next);
|
|
||||||
try {
|
|
||||||
if (next == null) {
|
|
||||||
await clearPromptFeedback(promptId);
|
|
||||||
} else {
|
|
||||||
await upsertPromptFeedback(promptId, { rating: next });
|
|
||||||
}
|
|
||||||
trackEvent(AnalyticsEvents.MESSAGE_RATED, {
|
|
||||||
rating: next ?? 'cleared',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setRating(prev);
|
|
||||||
onRatingChange?.(prev);
|
|
||||||
showToast('Could not save rating', 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumb = async (
|
|
||||||
next: PromptRating,
|
|
||||||
event: React.MouseEvent<HTMLElement>,
|
|
||||||
) => {
|
|
||||||
if (rating === next) {
|
|
||||||
await applyRating(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await applyRating(next);
|
|
||||||
if (next === 'down') {
|
|
||||||
setReasonAnchor(event.currentTarget);
|
|
||||||
setReasonCode(null);
|
|
||||||
setReasonComment('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitReason = async () => {
|
|
||||||
if (!promptId || rating !== 'down') {
|
|
||||||
setReasonAnchor(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await upsertPromptFeedback(promptId, {
|
|
||||||
rating: 'down',
|
|
||||||
reason: reasonCode ?? undefined,
|
|
||||||
comment: reasonComment.trim() || undefined,
|
|
||||||
});
|
|
||||||
trackEvent(AnalyticsEvents.MESSAGE_RATING_REASON, {
|
|
||||||
reason: reasonCode ?? 'none',
|
|
||||||
hasComment: Boolean(reasonComment.trim()),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
showToast('Could not save feedback reason', 'error');
|
|
||||||
} finally {
|
|
||||||
setReasonAnchor(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = async (format: ExportFormat) => {
|
|
||||||
setExportAnchor(null);
|
|
||||||
const turn: ExportTurn = {
|
|
||||||
role: userCreated ? 'user' : 'assistant',
|
|
||||||
message: rawMarkdown,
|
|
||||||
timestamp: createdTimestamp ?? null,
|
|
||||||
citations: userCreated ? [] : citations,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await exportChat({
|
|
||||||
format,
|
|
||||||
scope: 'message',
|
|
||||||
title: conversationTitle,
|
|
||||||
turns: [turn],
|
|
||||||
});
|
|
||||||
trackEvent(AnalyticsEvents.MESSAGE_EXPORTED, {
|
|
||||||
format,
|
|
||||||
scope: 'message',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
showToast('Export failed', 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionsRow
|
|
||||||
ref={rowRef}
|
|
||||||
$alwaysVisible={alwaysVisible}
|
|
||||||
data-message-actions
|
|
||||||
onFocusCapture={() => setFocused(true)}
|
|
||||||
onBlurCapture={(e) => {
|
|
||||||
if (!rowRef.current?.contains(e.relatedTarget as Node)) {
|
|
||||||
setFocused(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip title={copied ? 'Copied' : 'Copy markdown'}>
|
|
||||||
<ActionIconButton
|
|
||||||
aria-label="Copy message as markdown"
|
|
||||||
size="small"
|
|
||||||
onClick={() => void handleCopy()}
|
|
||||||
>
|
|
||||||
{copied ? <Check fontSize="small" /> : <ContentCopy fontSize="small" />}
|
|
||||||
</ActionIconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{!userCreated && (
|
|
||||||
<>
|
|
||||||
<Tooltip title="Thumbs up">
|
|
||||||
<span>
|
|
||||||
<ActionIconButton
|
|
||||||
aria-label="Thumbs up"
|
|
||||||
size="small"
|
|
||||||
disabled={!promptId}
|
|
||||||
onClick={(e) => void handleThumb('up', e)}
|
|
||||||
>
|
|
||||||
{rating === 'up' ? (
|
|
||||||
<ThumbUp fontSize="small" />
|
|
||||||
) : (
|
|
||||||
<ThumbUpOutlined fontSize="small" />
|
|
||||||
)}
|
|
||||||
</ActionIconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Thumbs down">
|
|
||||||
<span>
|
|
||||||
<ActionIconButton
|
|
||||||
aria-label="Thumbs down"
|
|
||||||
size="small"
|
|
||||||
disabled={!promptId}
|
|
||||||
onClick={(e) => void handleThumb('down', e)}
|
|
||||||
>
|
|
||||||
{rating === 'down' ? (
|
|
||||||
<ThumbDown fontSize="small" />
|
|
||||||
) : (
|
|
||||||
<ThumbDownOutlined fontSize="small" />
|
|
||||||
)}
|
|
||||||
</ActionIconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip title="Export">
|
|
||||||
<ActionIconButton
|
|
||||||
aria-label="Export message"
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => setExportAnchor(e.currentTarget)}
|
|
||||||
>
|
|
||||||
<FileDownload fontSize="small" />
|
|
||||||
</ActionIconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Menu
|
|
||||||
anchorEl={exportAnchor}
|
|
||||||
open={Boolean(exportAnchor)}
|
|
||||||
onClose={() => setExportAnchor(null)}
|
|
||||||
>
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
['pdf', 'PDF'],
|
|
||||||
['csv', 'CSV'],
|
|
||||||
['xlsx', 'Excel (.xlsx)'],
|
|
||||||
['txt', 'Plain text'],
|
|
||||||
] as [ExportFormat, string][]
|
|
||||||
).map(([format, label]) => (
|
|
||||||
<MenuItem key={format} onClick={() => void handleExport(format)}>
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
open={Boolean(reasonAnchor)}
|
|
||||||
anchorEl={reasonAnchor}
|
|
||||||
onClose={() => setReasonAnchor(null)}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
|
||||||
>
|
|
||||||
<Stack spacing={1.25} sx={{ p: 1.5, width: 280, maxWidth: '90vw' }}>
|
|
||||||
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>
|
|
||||||
What went wrong? (optional)
|
|
||||||
</div>
|
|
||||||
<Stack direction="row" flexWrap="wrap" gap={0.75}>
|
|
||||||
{DOWN_REASONS.map((reason) => (
|
|
||||||
<Chip
|
|
||||||
key={reason.code}
|
|
||||||
label={reason.label}
|
|
||||||
size="small"
|
|
||||||
color={reasonCode === reason.code ? 'primary' : 'default'}
|
|
||||||
onClick={() => setReasonCode(reason.code)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
placeholder="Additional details (optional)"
|
|
||||||
value={reasonComment}
|
|
||||||
onChange={(e) => setReasonComment(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button variant="contained" size="small" onClick={() => void submitReason()}>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Popover>
|
|
||||||
</ActionsRow>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MessageActions;
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
|
||||||
import styled, { useTheme } from 'styled-components';
|
|
||||||
import { AxiosResponse } from 'axios';
|
|
||||||
import { axiosInstance } from '../../../axiosApi';
|
|
||||||
|
|
||||||
export type PromptHeatmapData = {
|
|
||||||
tz: string;
|
|
||||||
total: number;
|
|
||||||
max: number;
|
|
||||||
days: string[];
|
|
||||||
hours: number[];
|
|
||||||
matrix: number[][];
|
|
||||||
most_active_day: string | null;
|
|
||||||
most_active_hour: number | null;
|
|
||||||
peak_cell: { day: string; hour: number; count: number } | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 2rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const CardTitle = styled.h2`
|
|
||||||
font-size: 1.8rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Subtitle = styled.p`
|
|
||||||
margin: 0.75rem 0 1.25rem 0;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.7;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const GridScroll = styled.div`
|
|
||||||
overflow-x: auto;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const HeatmapGrid = styled.div`
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 3rem repeat(24, minmax(14px, 1fr));
|
|
||||||
gap: 3px;
|
|
||||||
min-width: 520px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Corner = styled.div``;
|
|
||||||
|
|
||||||
const AxisLabel = styled.div`
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.55;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DayLabel = styled(AxisLabel)`
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding-left: 0.15rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Cell = styled.button<{ $intensity: number; $color: string }>`
|
|
||||||
aspect-ratio: 1;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0;
|
|
||||||
cursor: default;
|
|
||||||
background: ${({ $intensity, $color, theme }) =>
|
|
||||||
$intensity <= 0
|
|
||||||
? theme.darkMode
|
|
||||||
? 'rgba(255,255,255,0.08)'
|
|
||||||
: 'rgba(0,0,0,0.08)'
|
|
||||||
: $color};
|
|
||||||
opacity: ${({ $intensity }) => ($intensity <= 0 ? 1 : 0.35 + $intensity * 0.65)};
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
outline: 1px solid ${({ theme }) => theme.colors.text}66;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Footer = styled.p`
|
|
||||||
margin: 1rem 0 0 0;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.7;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StatusText = styled.p`
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.7;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => {
|
|
||||||
const cleaned = hex.replace('#', '');
|
|
||||||
if (cleaned.length !== 6) return null;
|
|
||||||
return {
|
|
||||||
r: parseInt(cleaned.slice(0, 2), 16),
|
|
||||||
g: parseInt(cleaned.slice(2, 4), 16),
|
|
||||||
b: parseInt(cleaned.slice(4, 6), 16),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const PromptHeatmapCard = (): JSX.Element => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const [data, setData] = useState<PromptHeatmapData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const [hover, setHover] = useState<{ day: string; hour: number; count: number } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const response: AxiosResponse<PromptHeatmapData> = await axiosInstance.get(
|
|
||||||
'/analytics/user_prompt_heatmap/',
|
|
||||||
{ params: { tz } }
|
|
||||||
);
|
|
||||||
if (!cancelled) {
|
|
||||||
setData(response.data);
|
|
||||||
setError(false);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(true);
|
|
||||||
setData(null);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const themeColor = theme?.main || '#4a90e2';
|
|
||||||
const rgb = useMemo(() => hexToRgb(themeColor), [themeColor]);
|
|
||||||
|
|
||||||
const cellColor = (count: number, max: number) => {
|
|
||||||
if (!rgb || max <= 0 || count <= 0) return themeColor;
|
|
||||||
const t = count / max;
|
|
||||||
return `rgb(${Math.round(rgb.r * (0.4 + 0.6 * t))}, ${Math.round(
|
|
||||||
rgb.g * (0.4 + 0.6 * t)
|
|
||||||
)}, ${Math.round(rgb.b * (0.4 + 0.6 * t))})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hourLabels = [0, 6, 12, 18, 23];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GlassCard>
|
|
||||||
<CardTitle>Prompt activity</CardTitle>
|
|
||||||
<Subtitle>
|
|
||||||
When you send prompts across the week (local timezone
|
|
||||||
{data?.tz ? `: ${data.tz}` : ''}).
|
|
||||||
</Subtitle>
|
|
||||||
|
|
||||||
{loading && <StatusText>Loading heatmap…</StatusText>}
|
|
||||||
{error && <StatusText>Could not load prompt activity.</StatusText>}
|
|
||||||
{!loading && !error && data && (
|
|
||||||
<>
|
|
||||||
<GridScroll>
|
|
||||||
<HeatmapGrid role="img" aria-label="Prompt activity heatmap by weekday and hour">
|
|
||||||
<Corner />
|
|
||||||
{data.hours.map((hour) => (
|
|
||||||
<AxisLabel key={`h-${hour}`}>{hourLabels.includes(hour) ? hour : ''}</AxisLabel>
|
|
||||||
))}
|
|
||||||
{data.days.map((day, dayIdx) => (
|
|
||||||
<React.Fragment key={day}>
|
|
||||||
<DayLabel>{day}</DayLabel>
|
|
||||||
{data.hours.map((hour) => {
|
|
||||||
const count = data.matrix[dayIdx]?.[hour] ?? 0;
|
|
||||||
const intensity = data.max > 0 ? count / data.max : 0;
|
|
||||||
return (
|
|
||||||
<Cell
|
|
||||||
key={`${day}-${hour}`}
|
|
||||||
type="button"
|
|
||||||
$intensity={intensity}
|
|
||||||
$color={cellColor(count, data.max)}
|
|
||||||
aria-label={`${day} ${hour}:00 — ${count} prompts`}
|
|
||||||
onMouseEnter={() => setHover({ day, hour, count })}
|
|
||||||
onMouseLeave={() => setHover(null)}
|
|
||||||
onFocus={() => setHover({ day, hour, count })}
|
|
||||||
onBlur={() => setHover(null)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</HeatmapGrid>
|
|
||||||
</GridScroll>
|
|
||||||
<Footer>
|
|
||||||
{hover
|
|
||||||
? `${hover.day} ${hover.hour}:00 — ${hover.count} prompt${hover.count === 1 ? '' : 's'}`
|
|
||||||
: data.total === 0
|
|
||||||
? 'No prompts yet. Chat a bit and this grid fills in.'
|
|
||||||
: [
|
|
||||||
`${data.total} prompt${data.total === 1 ? '' : 's'} total`,
|
|
||||||
data.most_active_day ? `Most active day: ${data.most_active_day}` : null,
|
|
||||||
data.most_active_hour != null
|
|
||||||
? `Most active hour: ${data.most_active_hour}:00`
|
|
||||||
: null,
|
|
||||||
data.peak_cell
|
|
||||||
? `Peak: ${data.peak_cell.day} ${data.peak_cell.hour}:00 (${data.peak_cell.count})`
|
|
||||||
: null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ')}
|
|
||||||
</Footer>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</GlassCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PromptHeatmapCard;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import SsoButtons from './SsoButtons';
|
|
||||||
import { startOAuth } from '../../auth/sso';
|
|
||||||
|
|
||||||
jest.mock('../../auth/sso', () => ({
|
|
||||||
startOAuth: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('SsoButtons', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.mocked(startOAuth).mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders nothing when no providers enabled', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<SsoButtons intent="login" providers={{ google: false, microsoft: false }} />
|
|
||||||
);
|
|
||||||
expect(container).toBeEmptyDOMElement();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses brand-compliant labels and logos for login', () => {
|
|
||||||
render(<SsoButtons intent="login" providers={{ google: true, microsoft: true }} />);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Sign in with Microsoft' })).toBeInTheDocument();
|
|
||||||
expect(document.querySelectorAll('svg').length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses Sign up with Google for signup intent', () => {
|
|
||||||
render(<SsoButtons intent="signup" providers={{ google: true }} />);
|
|
||||||
expect(screen.getByRole('button', { name: 'Sign up with Google' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts OAuth for the selected provider', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<SsoButtons intent="login" providers={{ google: true, microsoft: true }} />);
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Continue with Google' }));
|
|
||||||
expect(startOAuth).toHaveBeenCalledWith('google', 'login');
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Sign in with Microsoft' }));
|
|
||||||
expect(startOAuth).toHaveBeenCalledWith('microsoft', 'login');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -20,32 +20,21 @@ const Divider = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ButtonStack = styled.div`
|
const SsoButton = styled.button`
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 0.6rem;
|
background: rgba(255, 255, 255, 0.06);
|
||||||
margin-top: 0.6rem;
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
`;
|
border-radius: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
/** Shared layout for IdP buttons — equal size / visual weight. */
|
padding: 0.85rem 1rem;
|
||||||
const SsoButtonBase = styled.button`
|
font-size: 0.95rem;
|
||||||
display: flex;
|
font-weight: 600;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 2.75rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
padding: 0.65rem 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: 'Roboto', 'Google Sans', system-ui, sans-serif;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
|
transition: background 0.2s ease, transform 0.2s ease;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,93 +45,6 @@ const SsoButtonBase = styled.button`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/**
|
|
||||||
* Google dark-theme button per
|
|
||||||
* https://developers.google.com/identity/branding-guidelines
|
|
||||||
* Fill #131314, stroke #8E918F, text #E3E3E3; multicolor G on white.
|
|
||||||
*/
|
|
||||||
const GoogleButton = styled(SsoButtonBase)`
|
|
||||||
background: #131314;
|
|
||||||
border: 1px solid #8e918f;
|
|
||||||
color: #e3e3e3;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: #1e1f20;
|
|
||||||
border-color: #a8aba9;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Microsoft dark-theme button — logo + "Sign in with Microsoft"
|
|
||||||
* https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps
|
|
||||||
*/
|
|
||||||
const MicrosoftButton = styled(SsoButtonBase)`
|
|
||||||
background: #2f2f2f;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
color: #ffffff;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: #3b3b3b;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const GoogleLogoBadge = styled.span`
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 0.125rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const LogoMark = styled.span`
|
|
||||||
display: inline-flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
/** Official multicolor Google "G" (standard color Super G). */
|
|
||||||
const GoogleGIcon = (): JSX.Element => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
|
|
||||||
<path
|
|
||||||
fill="#EA4335"
|
|
||||||
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#4285F4"
|
|
||||||
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#FBBC05"
|
|
||||||
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#34A853"
|
|
||||||
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
|
|
||||||
/>
|
|
||||||
<path fill="none" d="M0 0h48v48H0z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Official Microsoft four-square logo — do not recolor. */
|
|
||||||
const MicrosoftLogoIcon = (): JSX.Element => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" aria-hidden="true" focusable="false">
|
|
||||||
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
|
|
||||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
|
|
||||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
|
|
||||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type OAuthProviderFlags = {
|
export type OAuthProviderFlags = {
|
||||||
google?: boolean;
|
google?: boolean;
|
||||||
microsoft?: boolean;
|
microsoft?: boolean;
|
||||||
@@ -154,9 +56,6 @@ type SsoButtonsProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const googleLabel = (intent: 'login' | 'signup'): string =>
|
|
||||||
intent === 'signup' ? 'Sign up with Google' : 'Continue with Google';
|
|
||||||
|
|
||||||
const SsoButtons = ({ intent, providers, disabled = false }: SsoButtonsProps): JSX.Element | null => {
|
const SsoButtons = ({ intent, providers, disabled = false }: SsoButtonsProps): JSX.Element | null => {
|
||||||
const google = Boolean(providers.google);
|
const google = Boolean(providers.google);
|
||||||
const microsoft = Boolean(providers.microsoft);
|
const microsoft = Boolean(providers.microsoft);
|
||||||
@@ -167,34 +66,24 @@ const SsoButtons = ({ intent, providers, disabled = false }: SsoButtonsProps): J
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Divider>or continue with</Divider>
|
<Divider>or continue with</Divider>
|
||||||
<ButtonStack>
|
|
||||||
{google && (
|
{google && (
|
||||||
<GoogleButton
|
<SsoButton
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => startOAuth('google', intent)}
|
onClick={() => startOAuth('google', intent)}
|
||||||
>
|
>
|
||||||
<GoogleLogoBadge>
|
Continue with Google
|
||||||
<LogoMark>
|
</SsoButton>
|
||||||
<GoogleGIcon />
|
|
||||||
</LogoMark>
|
|
||||||
</GoogleLogoBadge>
|
|
||||||
{googleLabel(intent)}
|
|
||||||
</GoogleButton>
|
|
||||||
)}
|
)}
|
||||||
{microsoft && (
|
{microsoft && (
|
||||||
<MicrosoftButton
|
<SsoButton
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => startOAuth('microsoft', intent)}
|
onClick={() => startOAuth('microsoft', intent)}
|
||||||
>
|
>
|
||||||
<LogoMark>
|
Continue with Microsoft
|
||||||
<MicrosoftLogoIcon />
|
</SsoButton>
|
||||||
</LogoMark>
|
|
||||||
Sign in with Microsoft
|
|
||||||
</MicrosoftButton>
|
|
||||||
)}
|
)}
|
||||||
</ButtonStack>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
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,
|
||||||
@@ -50,16 +48,6 @@ 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`
|
||||||
@@ -105,11 +93,6 @@ 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`
|
||||||
@@ -132,23 +115,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -162,10 +128,6 @@ 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> => {
|
||||||
@@ -179,50 +141,17 @@ const ThemeSettingsCard = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getConversationPreferences() {
|
async function getConversationOrder() {
|
||||||
setPrefsLoading(true);
|
|
||||||
setPrefsError('');
|
|
||||||
try {
|
try {
|
||||||
const { data }: AxiosResponse<PreferencesType> = await axiosInstance.get(
|
const { data, }: AxiosResponse<PreferencesType> = await axiosInstance.get(`/conversation_preferences`);
|
||||||
`/conversation_preferences`
|
|
||||||
);
|
|
||||||
setOrder(data.order);
|
setOrder(data.order);
|
||||||
setUseConversationContext(Boolean(data.use_conversation_context));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPrefsError(
|
console.log(error)
|
||||||
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(() => {
|
||||||
getConversationPreferences();
|
getConversationOrder();
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDarkMode = () => setDarkMode(dispatch, !darkMode);
|
const handleDarkMode = () => setDarkMode(dispatch, !darkMode);
|
||||||
@@ -264,7 +193,6 @@ 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 })
|
||||||
@@ -274,30 +202,6 @@ 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { Snackbar, Alert } from '@mui/material';
|
|
||||||
import { subscribeToast } from '../../utils/toastBus';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global snackbar host for copy/export/feedback errors (#97).
|
|
||||||
*/
|
|
||||||
const ToastHost = (): JSX.Element => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [severity, setSeverity] = useState<'error' | 'success' | 'info'>('info');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return subscribeToast((nextMessage, nextSeverity) => {
|
|
||||||
setMessage(nextMessage);
|
|
||||||
setSeverity(nextSeverity);
|
|
||||||
setOpen(true);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Snackbar
|
|
||||||
open={open}
|
|
||||||
autoHideDuration={4000}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
>
|
|
||||||
<Alert
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
severity={severity}
|
|
||||||
variant="filled"
|
|
||||||
sx={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ToastHost;
|
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { axiosInstance } from '../../../axiosApi';
|
import { axiosInstance } from '../../../axiosApi';
|
||||||
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
|
||||||
import {
|
import {
|
||||||
canOpenBillingPortal,
|
|
||||||
formatBillingDate,
|
formatBillingDate,
|
||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
isComplimentarySubscription,
|
|
||||||
SubscriptionMe,
|
SubscriptionMe,
|
||||||
} from '../../utils/finance';
|
} from '../../utils/finance';
|
||||||
import type { FinanceInvoice } from '../../utils/finance';
|
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
const GlassCard = styled.div`
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
@@ -110,27 +106,6 @@ const UpgradeHint = styled.p`
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const UpgradeButton = styled.button`
|
|
||||||
margin-top: 1rem;
|
|
||||||
background: ${({ theme }) => theme.main};
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: #fff;
|
|
||||||
padding: 0.7rem 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function clampPct(used: number, quota: number): number {
|
function clampPct(used: number, quota: number): number {
|
||||||
if (quota <= 0) return 0;
|
if (quota <= 0) return 0;
|
||||||
return Math.min(100, Math.round((used / quota) * 1000) / 10);
|
return Math.min(100, Math.round((used / quota) * 1000) / 10);
|
||||||
@@ -166,26 +141,15 @@ const UsageMeter = ({ label, meta, usedLabel, pct }: MeterProps): JSX.Element =>
|
|||||||
|
|
||||||
const UsageSummaryCard = (): JSX.Element => {
|
const UsageSummaryCard = (): JSX.Element => {
|
||||||
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
||||||
const [hasPortalAccess, setHasPortalAccess] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [upgradeLoading, setUpgradeLoading] = useState(false);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [subscriptionResponse, invoiceResponse] = await Promise.all([
|
const response = await axiosInstance.get<SubscriptionMe>('/finance/subscription/');
|
||||||
axiosInstance.get<SubscriptionMe>('/monetization/subscription/'),
|
setSubscription(response.data || null);
|
||||||
axiosInstance.get<FinanceInvoice[]>('/monetization/invoices/'),
|
|
||||||
]);
|
|
||||||
setSubscription(subscriptionResponse.data || null);
|
|
||||||
setHasPortalAccess(
|
|
||||||
canOpenBillingPortal(
|
|
||||||
Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
setSubscription(null);
|
setSubscription(null);
|
||||||
setHasPortalAccess(false);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -210,39 +174,6 @@ const UsageSummaryCard = (): JSX.Element => {
|
|||||||
? 0
|
? 0
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const complimentary = isComplimentarySubscription(subscription, hasPortalAccess);
|
|
||||||
const showUpgrade = Boolean(subscription?.plan) && hasPortalAccess && !complimentary;
|
|
||||||
|
|
||||||
const handleUpgrade = async () => {
|
|
||||||
trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, {
|
|
||||||
source: 'usage_card',
|
|
||||||
});
|
|
||||||
const billing = document.querySelector('[data-testid="billing-section"]');
|
|
||||||
if (billing) {
|
|
||||||
billing.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
const buttons = Array.from(billing.querySelectorAll('button'));
|
|
||||||
const upgrade = buttons.find((btn) =>
|
|
||||||
/upgrade/i.test(btn.textContent || '')
|
|
||||||
);
|
|
||||||
upgrade?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUpgradeLoading(true);
|
|
||||||
try {
|
|
||||||
const returnUrl = `${window.location.origin}/account/`;
|
|
||||||
const response = await axiosInstance.post<{ portal_url: string }>(
|
|
||||||
'/monetization/portal/',
|
|
||||||
{ return_url: returnUrl }
|
|
||||||
);
|
|
||||||
if (response.data?.portal_url) {
|
|
||||||
trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED, { intent: 'upgrade' });
|
|
||||||
window.location.assign(response.data.portal_url);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setUpgradeLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard data-testid="usage-summary-card">
|
<GlassCard data-testid="usage-summary-card">
|
||||||
<CardTitle>Usage limit</CardTitle>
|
<CardTitle>Usage limit</CardTitle>
|
||||||
@@ -293,27 +224,9 @@ const UsageSummaryCard = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showUpgrade ? (
|
|
||||||
<>
|
|
||||||
<UpgradeHint>
|
|
||||||
Need more capacity? Upgrade your plan in Billing, or open upgrade
|
|
||||||
below.
|
|
||||||
</UpgradeHint>
|
|
||||||
<UpgradeButton
|
|
||||||
type="button"
|
|
||||||
onClick={handleUpgrade}
|
|
||||||
disabled={upgradeLoading}
|
|
||||||
>
|
|
||||||
{upgradeLoading ? 'Opening…' : 'Upgrade'}
|
|
||||||
</UpgradeButton>
|
|
||||||
</>
|
|
||||||
) : complimentary ? (
|
|
||||||
<UpgradeHint>Complimentary access — usage limits follow your granted plan.</UpgradeHint>
|
|
||||||
) : (
|
|
||||||
<UpgradeHint>
|
<UpgradeHint>
|
||||||
Need more capacity? Manage or upgrade your plan in Billing below.
|
Need more capacity? Manage or upgrade your plan in Billing below.
|
||||||
</UpgradeHint>
|
</UpgradeHint>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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;
|
||||||
@@ -29,7 +28,6 @@ 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,
|
||||||
@@ -45,9 +43,6 @@ const AccountProvider = ({children}: AccountProviderProps) => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
setAccount(account);
|
setAccount(account);
|
||||||
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
|
|
||||||
console.warn('RevenueCat logIn', err)
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
import { createContext, ReactNode, 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,12 +6,6 @@ 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 {
|
|
||||||
ActivityHistoryEntry,
|
|
||||||
parseCitationsData,
|
|
||||||
parseStatusData,
|
|
||||||
parseVersionedFrame,
|
|
||||||
} from "../utils/wsFrames";
|
|
||||||
|
|
||||||
type MessageProviderProps ={
|
type MessageProviderProps ={
|
||||||
children? : ReactNode;
|
children? : ReactNode;
|
||||||
@@ -26,12 +20,6 @@ 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 = {
|
||||||
@@ -42,11 +30,6 @@ 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);
|
||||||
@@ -63,10 +46,6 @@ 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);
|
||||||
@@ -74,20 +53,9 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
const selectedConversationRef = useRef<undefined | number>(undefined)
|
const selectedConversationRef = useRef<undefined | number>(undefined)
|
||||||
const isGeneratingRef = useRef(false)
|
const isGeneratingRef = useRef(false)
|
||||||
const prevReconnectGenerationRef = useRef(0)
|
const prevReconnectGenerationRef = useRef(0)
|
||||||
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([])
|
||||||
@@ -108,11 +76,8 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
message: item.message,
|
message: item.message,
|
||||||
user_created: item.user_created,
|
user_created: item.user_created,
|
||||||
created_timestamp: item.created_timestamp,
|
created_timestamp: item.created_timestamp,
|
||||||
created: item.created,
|
|
||||||
tokens_in: item.tokens_in ?? null,
|
tokens_in: item.tokens_in ?? null,
|
||||||
tokens_out: item.tokens_out ?? null,
|
tokens_out: item.tokens_out ?? null,
|
||||||
citations: item.citations ?? [],
|
|
||||||
feedback: item.feedback ?? null,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (tempConversations.length === 1) {
|
if (tempConversations.length === 1) {
|
||||||
@@ -128,27 +93,10 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedulePostStreamRefetch = useCallback(() => {
|
|
||||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
|
|
||||||
const conversationId = selectedConversationRef.current
|
|
||||||
if (!conversationId) return
|
|
||||||
// Backend saves the assistant prompt after END (+ citations frame); brief delay
|
|
||||||
// hydrates prompt ids, persisted citations, and feedback.
|
|
||||||
refetchTimerRef.current = setTimeout(() => {
|
|
||||||
void GetConversationDetails(conversationId)
|
|
||||||
}, 800)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetConversationDetails(selectedConversation)
|
GetConversationDetails(selectedConversation)
|
||||||
}, [selectedConversation])
|
}, [selectedConversation])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Streaming recovery: on reconnect after a drop mid-stream, refetch conversation
|
// Streaming recovery: on reconnect after a drop mid-stream, refetch conversation
|
||||||
// and mark the in-flight assistant turn interrupted (no resume protocol on backend).
|
// and mark the in-flight assistant turn interrupted (no resume protocol on backend).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -172,7 +120,6 @@ 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) {
|
||||||
@@ -200,80 +147,21 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
|
|
||||||
/* subscribe to channel and register callback */
|
/* subscribe to channel and register callback */
|
||||||
subscribe(channelName, (message: string) => {
|
subscribe(channelName, (message: string) => {
|
||||||
/* Versioned frames (citations #98, status #96) — ignore unknown types safely */
|
/* when a message is received just add it to the UI */
|
||||||
const frame = parseVersionedFrame(message)
|
|
||||||
if (frame) {
|
|
||||||
if (frame.type === 'citations') {
|
|
||||||
const citations = parseCitationsData(frame.data)
|
|
||||||
const details = [...conversationRef.current]
|
|
||||||
for (let i = details.length - 1; i >= 0; i -= 1) {
|
|
||||||
if (!details[i].user_created && details[i].message) {
|
|
||||||
details[i] = new ConversationPrompt({
|
|
||||||
...details[i],
|
|
||||||
citations,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
conversationRef.current = details
|
|
||||||
setConversationDetails(details)
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){
|
if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){
|
||||||
messageResponsePart.current = 0
|
messageResponsePart.current = 0
|
||||||
|
|
||||||
conversationRef.current.pop()
|
conversationRef.current.pop()
|
||||||
|
|
||||||
const finalized = new ConversationPrompt({
|
//handleAssistantPrompt({prompt: messageRef.current})
|
||||||
message: `${messageRef.current}`,
|
setConversationDetails([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})])
|
||||||
user_created: false,
|
console.log([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})])
|
||||||
})
|
|
||||||
conversationRef.current = [...conversationRef.current, finalized]
|
|
||||||
setConversationDetails([...conversationRef.current])
|
|
||||||
messageRef.current = ''
|
messageRef.current = ''
|
||||||
setStateMessage('')
|
setStateMessage('')
|
||||||
isGeneratingRef.current = false
|
isGeneratingRef.current = false
|
||||||
setIsGeneratingMessage(false)
|
setIsGeneratingMessage(false)
|
||||||
setStreamInterrupted(false)
|
setStreamInterrupted(false)
|
||||||
clearActivity()
|
|
||||||
schedulePostStreamRefetch()
|
|
||||||
}
|
}
|
||||||
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
|
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
|
||||||
conversationRef.current = conversationDetails
|
conversationRef.current = conversationDetails
|
||||||
@@ -288,26 +176,23 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
setStreamInterrupted(false)
|
setStreamInterrupted(false)
|
||||||
messageResponsePart.current = 1
|
messageResponsePart.current = 1
|
||||||
}else{
|
}else{
|
||||||
if (messageResponsePart.current === 1){
|
|
||||||
isGeneratingRef.current = true
|
isGeneratingRef.current = true
|
||||||
setIsGeneratingMessage(true)
|
setIsGeneratingMessage(true)
|
||||||
|
if (messageResponsePart.current === 1){
|
||||||
// this has to do with the conversation id
|
// this has to do with the conversation id
|
||||||
if(!selectedConversation){
|
if(!selectedConversation){
|
||||||
const conversationId = Number(message);
|
const conversationId = Number(message);
|
||||||
setSelectedConversation(conversationId)
|
setSelectedConversation(conversationId)
|
||||||
selectedConversationRef.current = conversationId
|
|
||||||
trackEvent(AnalyticsEvents.CONVERSATION_CREATED, {
|
trackEvent(AnalyticsEvents.CONVERSATION_CREATED, {
|
||||||
conversationId,
|
conversationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (messageResponsePart.current === 2){
|
else if (messageResponsePart.current === 2){
|
||||||
isGeneratingRef.current = true
|
|
||||||
setIsGeneratingMessage(true)
|
|
||||||
messageRef.current += message
|
messageRef.current += message
|
||||||
setStateMessage(messageRef.current)
|
setStateMessage(messageRef.current)
|
||||||
|
|
||||||
}
|
}
|
||||||
// ignore stray frames outside an active stream phase
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
@@ -316,7 +201,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, schedulePostStreamRefetch])
|
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation])
|
||||||
|
|
||||||
|
|
||||||
return(
|
return(
|
||||||
@@ -328,11 +213,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
isGeneratingMessage,
|
isGeneratingMessage,
|
||||||
streamInterrupted,
|
streamInterrupted,
|
||||||
clearStreamInterrupted,
|
clearStreamInterrupted,
|
||||||
activityStage,
|
|
||||||
activityLabel,
|
|
||||||
activityDetail,
|
|
||||||
activityHistory,
|
|
||||||
clearActivity,
|
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</MessageContext.Provider>
|
</MessageContext.Provider>
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import type { Citation } from './utils/wsFrames';
|
|
||||||
import type { PromptRating } from './utils/promptFeedback';
|
|
||||||
|
|
||||||
export type PromptFeedbackState = {
|
|
||||||
rating: PromptRating;
|
|
||||||
reason?: string | null;
|
|
||||||
comment?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Classes for the project */
|
/* Classes for the project */
|
||||||
export interface ConversationPromptType {
|
export interface ConversationPromptType {
|
||||||
id: number,
|
id: number,
|
||||||
message: string,
|
message: string,
|
||||||
user_created: boolean,
|
user_created: boolean,
|
||||||
created_timestamp?: Date,
|
created_timestamp: Date,
|
||||||
created?: Date | string,
|
|
||||||
tokens_in?: number | null,
|
tokens_in?: number | null,
|
||||||
tokens_out?: number | null,
|
tokens_out?: number | null,
|
||||||
citations?: Citation[],
|
|
||||||
feedback?: PromptFeedbackState | null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConversationPrompt{
|
export class ConversationPrompt{
|
||||||
@@ -27,8 +15,6 @@ export class ConversationPrompt{
|
|||||||
created_timestamp: Date = new Date();
|
created_timestamp: Date = new Date();
|
||||||
tokens_in: number | null = null;
|
tokens_in: number | null = null;
|
||||||
tokens_out: number | null = null;
|
tokens_out: number | null = null;
|
||||||
citations: Citation[] = [];
|
|
||||||
feedback: PromptFeedbackState | null = null;
|
|
||||||
|
|
||||||
constructor(initializer?: any){
|
constructor(initializer?: any){
|
||||||
if(!initializer) return;
|
if(!initializer) return;
|
||||||
@@ -36,11 +22,8 @@ export class ConversationPrompt{
|
|||||||
if (initializer.message) this.message = initializer.message;
|
if (initializer.message) this.message = initializer.message;
|
||||||
if (initializer.user_created) this.user_created = initializer.user_created;
|
if (initializer.user_created) this.user_created = initializer.user_created;
|
||||||
if (initializer.created_timestamp) this.created_timestamp = initializer.created_timestamp;
|
if (initializer.created_timestamp) this.created_timestamp = initializer.created_timestamp;
|
||||||
else if (initializer.created) this.created_timestamp = new Date(initializer.created);
|
|
||||||
if (initializer.tokens_in !== undefined) this.tokens_in = initializer.tokens_in;
|
if (initializer.tokens_in !== undefined) this.tokens_in = initializer.tokens_in;
|
||||||
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out;
|
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out;
|
||||||
if (Array.isArray(initializer.citations)) this.citations = initializer.citations;
|
|
||||||
if (initializer.feedback !== undefined) this.feedback = initializer.feedback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -255,7 +238,6 @@ 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;
|
||||||
@@ -269,11 +251,9 @@ 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 = '';
|
||||||
@@ -285,7 +265,6 @@ 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;
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
||||||
import { AxiosResponse } from 'axios';
|
|
||||||
import { axiosInstance } from '../../axiosApi';
|
|
||||||
import { AuthContext } from '../contexts/AuthContext';
|
|
||||||
import { planAllowsRag, SubscriptionMe } from '../utils/finance';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module-level cache so every component that calls useSubscription() during the
|
|
||||||
* same session shares one /monetization/subscription/ request instead of each
|
|
||||||
* mounting its own (e.g. Header2 + DocumentStoragePage on the same page).
|
|
||||||
*/
|
|
||||||
let cachedSubscription: SubscriptionMe | null = null;
|
|
||||||
let inFlightRequest: Promise<SubscriptionMe | null> | null = null;
|
|
||||||
|
|
||||||
async function loadSubscription(): Promise<SubscriptionMe | null> {
|
|
||||||
if (cachedSubscription) {
|
|
||||||
return cachedSubscription;
|
|
||||||
}
|
|
||||||
if (!inFlightRequest) {
|
|
||||||
inFlightRequest = axiosInstance
|
|
||||||
.get<SubscriptionMe>('/monetization/subscription/')
|
|
||||||
.then((response: AxiosResponse<SubscriptionMe>) => {
|
|
||||||
cachedSubscription = response.data || null;
|
|
||||||
return cachedSubscription;
|
|
||||||
})
|
|
||||||
.catch(() => null)
|
|
||||||
.finally(() => {
|
|
||||||
inFlightRequest = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return inFlightRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drops the cached subscription so the next useSubscription() call refetches (e.g. after sign-out or a plan change). */
|
|
||||||
export function resetSubscriptionCache(): void {
|
|
||||||
cachedSubscription = null;
|
|
||||||
inFlightRequest = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UseSubscriptionResult = {
|
|
||||||
subscription: SubscriptionMe | null;
|
|
||||||
loading: boolean;
|
|
||||||
hasRag: boolean;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Fetches /monetization/subscription/ once per session (shared across callers) and exposes plan-derived flags. */
|
|
||||||
export function useSubscription(): UseSubscriptionResult {
|
|
||||||
const { authenticated } = useContext(AuthContext);
|
|
||||||
const [subscription, setSubscription] = useState<SubscriptionMe | null>(cachedSubscription);
|
|
||||||
const [loading, setLoading] = useState(!cachedSubscription);
|
|
||||||
const mountedRef = useRef(true);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
if (!authenticated) {
|
|
||||||
setSubscription(null);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading((prev) => prev || !cachedSubscription);
|
|
||||||
const result = await loadSubscription();
|
|
||||||
if (mountedRef.current) {
|
|
||||||
setSubscription(result);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
mountedRef.current = true;
|
|
||||||
load();
|
|
||||||
return () => {
|
|
||||||
mountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authenticated) {
|
|
||||||
resetSubscriptionCache();
|
|
||||||
}
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
resetSubscriptionCache();
|
|
||||||
await load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscription,
|
|
||||||
loading,
|
|
||||||
hasRag: planAllowsRag(subscription),
|
|
||||||
refresh,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import styled from "styled-components";
|
|||||||
import ThemeSettingsCard from "../../components/ThemeSettingsCard/ThemeSettingsCard";
|
import ThemeSettingsCard from "../../components/ThemeSettingsCard/ThemeSettingsCard";
|
||||||
import UsageSummaryCard from "../../components/UsageSummaryCard/UsageSummaryCard";
|
import UsageSummaryCard from "../../components/UsageSummaryCard/UsageSummaryCard";
|
||||||
import BillingSection from "../../components/BillingSection/BillingSection";
|
import BillingSection from "../../components/BillingSection/BillingSection";
|
||||||
import DeleteAccountSection from "../../components/DeleteAccountSection/DeleteAccountSection";
|
|
||||||
|
|
||||||
// Styled Components
|
// Styled Components
|
||||||
const PageContainer = styled.div`
|
const PageContainer = styled.div`
|
||||||
@@ -420,7 +419,6 @@ const AccountPage = (): JSX.Element => {
|
|||||||
<p style={{ color: 'rgba(255,255,255,0.7)' }}>Account and prompt information will be available soon</p>
|
<p style={{ color: 'rgba(255,255,255,0.7)' }}>Account and prompt information will be available soon</p>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
<DeleteAccountSection />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { ThemeProvider } from 'styled-components';
|
|
||||||
import AnalyticsPage from './Analytics';
|
|
||||||
import { AccountContext } from '../../contexts/AccountContext';
|
|
||||||
import { AuthContext } from '../../contexts/AuthContext';
|
|
||||||
import { Account } from '../../data';
|
|
||||||
|
|
||||||
const mockGet = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('../../../axiosApi', () => ({
|
|
||||||
axiosInstance: {
|
|
||||||
get: (...args: unknown[]) => mockGet(...args),
|
|
||||||
defaults: { headers: { common: {} as Record<string, string | null> } },
|
|
||||||
},
|
|
||||||
applyAccessToken: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
|
||||||
jest.mock('../../components/Header2/Header2', () => () => null);
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
class ResizeObserverMock {
|
|
||||||
observe() {}
|
|
||||||
unobserve() {}
|
|
||||||
disconnect() {}
|
|
||||||
}
|
|
||||||
(global as unknown as { ResizeObserver: typeof ResizeObserverMock }).ResizeObserver =
|
|
||||||
ResizeObserverMock;
|
|
||||||
});
|
|
||||||
|
|
||||||
const theme = {
|
|
||||||
main: '#4a90e2',
|
|
||||||
focus: '#224466',
|
|
||||||
darkMode: true,
|
|
||||||
colors: {
|
|
||||||
text: '#ffffff',
|
|
||||||
cardBackground: 'rgba(0,0,0,0.3)',
|
|
||||||
cardBorder: 'rgba(255,255,255,0.1)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyHeatmap = {
|
|
||||||
tz: 'UTC',
|
|
||||||
total: 0,
|
|
||||||
max: 0,
|
|
||||||
days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
|
||||||
hours: Array.from({ length: 24 }, (_, i) => i),
|
|
||||||
matrix: Array.from({ length: 7 }, () => Array(24).fill(0)),
|
|
||||||
most_active_day: null,
|
|
||||||
most_active_hour: null,
|
|
||||||
peak_cell: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPage = (accountInit?: ConstructorParameters<typeof Account>[0]) => {
|
|
||||||
const account = new Account(accountInit || { email: 'user@example.com' });
|
|
||||||
return render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ThemeProvider theme={theme as never}>
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{
|
|
||||||
authenticated: true,
|
|
||||||
setAuthentication: jest.fn(),
|
|
||||||
needsNewPassword: false,
|
|
||||||
setNeedsNewPassword: jest.fn(),
|
|
||||||
loading: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccountContext.Provider value={{ account, setAccount: jest.fn() }}>
|
|
||||||
<AnalyticsPage />
|
|
||||||
</AccountContext.Provider>
|
|
||||||
</AuthContext.Provider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('AnalyticsPage (#94)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockGet.mockReset();
|
|
||||||
mockGet.mockImplementation((url: string) => {
|
|
||||||
if (url.includes('user_prompt_heatmap')) {
|
|
||||||
return Promise.resolve({ data: emptyHeatmap });
|
|
||||||
}
|
|
||||||
return Promise.resolve({ data: [] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows your activity section with heatmap for all users', async () => {
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
expect(await screen.findByText('Your activity')).toBeInTheDocument();
|
|
||||||
expect(await screen.findByText('Prompt activity')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Company')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows company section for company managers', async () => {
|
|
||||||
renderPage({
|
|
||||||
email: 'mgr@example.com',
|
|
||||||
is_company_manager: true,
|
|
||||||
company: { id: 1, name: 'Acme', state: '', zipcode: '', address: '' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByText('Company')).toBeInTheDocument();
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Team seat activity')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,9 +6,9 @@ import { axiosInstance } from "../../../axiosApi"
|
|||||||
import { AxiosResponse } from "axios"
|
import { AxiosResponse } from "axios"
|
||||||
import { AdminAnalytics, AdminAnalyticsType, CompanyUsageAnalytics, CompanyUsageAnalyticsType, UserConversationAnalytics, UserConvesationAnalyticsType, UserPromptAnalytics, UserPromptAnalyticsType } from "../../data"
|
import { AdminAnalytics, AdminAnalyticsType, CompanyUsageAnalytics, CompanyUsageAnalyticsType, UserConversationAnalytics, UserConvesationAnalyticsType, UserPromptAnalytics, UserPromptAnalyticsType } from "../../data"
|
||||||
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground"
|
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground"
|
||||||
import PromptHeatmapCard from "../../components/PromptHeatmapCard/PromptHeatmapCard"
|
|
||||||
import styled, { ThemeContext } from "styled-components"
|
import styled, { ThemeContext } from "styled-components"
|
||||||
|
|
||||||
|
// Styled Components
|
||||||
const PageContainer = styled.div`
|
const PageContainer = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -18,6 +18,7 @@ const PageContainer = styled.div`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: ${({ theme }) => theme.colors.text};
|
color: ${({ theme }) => theme.colors.text};
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
|
/* background-color: ${({ theme }) => theme.colors.background}; Removed to show particles */
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ContentWrapper = styled.div`
|
const ContentWrapper = styled.div`
|
||||||
@@ -41,29 +42,6 @@ const ContentWrapper = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Section = styled.section`
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SectionHeading = styled.h1`
|
|
||||||
width: 100%;
|
|
||||||
font-size: 1.35rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SectionHint = styled.p`
|
|
||||||
margin: -0.5rem 0 1.25rem 0;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.65;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
const GlassCard = styled.div`
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -118,11 +96,11 @@ const UserPromptAnalyticsCard = (): JSX.Element => {
|
|||||||
}, [])
|
}, [])
|
||||||
return (
|
return (
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<CardTitle>Prompt volume</CardTitle>
|
<CardTitle>Prompt Usage</CardTitle>
|
||||||
<ChartContainer>
|
<ChartContainer>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<BarChart data={data}>
|
<BarChart data={data}>
|
||||||
<XAxis dataKey="month" stroke={theme?.colors.text} />
|
<XAxis stroke={theme?.colors.text} />
|
||||||
<YAxis stroke={theme?.colors.text} />
|
<YAxis stroke={theme?.colors.text} />
|
||||||
<Legend wrapperStyle={{ color: theme?.colors.text }} />
|
<Legend wrapperStyle={{ color: theme?.colors.text }} />
|
||||||
<Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} />
|
<Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} />
|
||||||
@@ -154,11 +132,11 @@ const UserConversationAnalyticsCard = (): JSX.Element => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<CardTitle>Conversation volume</CardTitle>
|
<CardTitle>Conversation Usage</CardTitle>
|
||||||
<ChartContainer>
|
<ChartContainer>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<BarChart data={data}>
|
<BarChart data={data}>
|
||||||
<XAxis dataKey="month" stroke={theme?.colors.text} />
|
<XAxis stroke={theme?.colors.text} />
|
||||||
<YAxis stroke={theme?.colors.text} />
|
<YAxis stroke={theme?.colors.text} />
|
||||||
<Legend wrapperStyle={{ color: theme?.colors.text }} />
|
<Legend wrapperStyle={{ color: theme?.colors.text }} />
|
||||||
<Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} />
|
<Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} />
|
||||||
@@ -190,7 +168,7 @@ const CompanyUsageAnalyticsCard = (): JSX.Element => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<CardTitle>Team seat activity</CardTitle>
|
<CardTitle>Account Usage</CardTitle>
|
||||||
<ChartContainer>
|
<ChartContainer>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<BarChart data={data}>
|
<BarChart data={data}>
|
||||||
@@ -224,7 +202,7 @@ const AdminAnalyticsCard = (): JSX.Element => {
|
|||||||
}, [])
|
}, [])
|
||||||
return (
|
return (
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<CardTitle>Response times (ops)</CardTitle>
|
<CardTitle>Response Times</CardTitle>
|
||||||
<ChartContainer>
|
<ChartContainer>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<ComposedChart data={data}>
|
<ComposedChart data={data}>
|
||||||
@@ -249,57 +227,20 @@ const AdminAnalyticsCard = (): JSX.Element => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOpsAdmin = (email?: string, role?: string): boolean => {
|
|
||||||
if (role && ['admin', 'ops', 'staff'].includes(role.toLowerCase())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return email === "ryan+admin@aimloperations.com";
|
|
||||||
};
|
|
||||||
|
|
||||||
const AnalyticsInner = (): JSX.Element => {
|
const AnalyticsInner = (): JSX.Element => {
|
||||||
const { account } = useContext(AccountContext)
|
const { account } = useContext(AccountContext)
|
||||||
const showCompany = Boolean(account?.is_company_manager || account?.company);
|
|
||||||
const showAdmin = isOpsAdmin(account?.email, account?.role);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section aria-labelledby="analytics-you">
|
|
||||||
<SectionHeading id="analytics-you">Your activity</SectionHeading>
|
|
||||||
<SectionHint>
|
|
||||||
Personal prompt timing and volume. Other users' private messages are never shown here.
|
|
||||||
</SectionHint>
|
|
||||||
<PromptHeatmapCard />
|
|
||||||
<GridContainer>
|
<GridContainer>
|
||||||
<UserConversationAnalyticsCard />
|
<UserConversationAnalyticsCard />
|
||||||
<UserPromptAnalyticsCard />
|
<UserPromptAnalyticsCard />
|
||||||
</GridContainer>
|
</GridContainer>
|
||||||
</Section>
|
|
||||||
|
|
||||||
{showCompany && (
|
{account?.is_company_manager ? <CompanyUsageAnalyticsCard /> : <></>}
|
||||||
<Section aria-labelledby="analytics-company">
|
{account?.email === "ryan+admin@aimloperations.com" ? <AdminAnalyticsCard /> : <></>}
|
||||||
<SectionHeading id="analytics-company">Company</SectionHeading>
|
|
||||||
<SectionHint>
|
|
||||||
Aggregated seat and usage trends for your workspace. No message content.
|
|
||||||
</SectionHint>
|
|
||||||
{account?.is_company_manager ? (
|
|
||||||
<CompanyUsageAnalyticsCard />
|
|
||||||
) : (
|
|
||||||
<GlassCard>
|
|
||||||
<CardTitle>Company insights</CardTitle>
|
|
||||||
<p style={{ opacity: 0.7, margin: 0 }}>
|
|
||||||
Detailed team seat charts are available to company managers.
|
|
||||||
</p>
|
|
||||||
</GlassCard>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAdmin && (
|
|
||||||
<Section aria-labelledby="analytics-ops">
|
|
||||||
<SectionHeading id="analytics-ops">Operations</SectionHeading>
|
|
||||||
<AdminAnalyticsCard />
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import React, { useContext, useEffect, useRef, useState } from "react";
|
|||||||
import styled, { ThemeContext } from "styled-components";
|
import styled, { ThemeContext } from "styled-components";
|
||||||
import { Formik, Form, Field } from "formik";
|
import { Formik, Form, Field } from "formik";
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
import { AttachFile, Delete, Send, Close, FileDownload } from "@mui/icons-material";
|
import { AttachFile, Delete, Send, Menu, Close } from "@mui/icons-material"; // Keeping icons for now, can replace later if needed
|
||||||
import { Tooltip, Menu, MenuItem, IconButton as MuiIconButton } from "@mui/material";
|
import { Tooltip } from "@mui/material";
|
||||||
import Markdown from "markdown-to-jsx";
|
import Markdown from "markdown-to-jsx";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,9 +17,6 @@ import ParticleBackground from "../../components/ParticleBackground/ParticleBack
|
|||||||
|
|
||||||
import Header2 from "../../components/Header2/Header2";
|
import Header2 from "../../components/Header2/Header2";
|
||||||
import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
|
import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
|
||||||
import { exportChat, type ExportFormat } from "../../utils/export/exportChat";
|
|
||||||
import { showToast } from "../../utils/toastBus";
|
|
||||||
import type { PromptRating } from "../../utils/promptFeedback";
|
|
||||||
|
|
||||||
// Styled Components
|
// Styled Components
|
||||||
const PageContainer = styled.div`
|
const PageContainer = styled.div`
|
||||||
@@ -66,6 +63,34 @@ const Sidebar = styled.div<{ $isOpen: boolean }>`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const MobileSidebarToggle = styled.button`
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(4.75rem + env(safe-area-inset-top, 0px)); /* Below header */
|
||||||
|
left: 1rem;
|
||||||
|
z-index: 15;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: ${({ theme }) => theme.main};
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const Overlay = styled.div<{ $isOpen: boolean }>`
|
const Overlay = styled.div<{ $isOpen: boolean }>`
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -155,18 +180,6 @@ const ChatArea = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ChatToolbar = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.25rem 2rem 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
padding: 0.25rem 0.85rem 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const InputArea = styled.div`
|
const InputArea = styled.div`
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -252,6 +265,32 @@ 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;
|
||||||
@@ -317,13 +356,11 @@ 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 => {
|
||||||
@@ -346,68 +383,12 @@ 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);
|
||||||
const theme = useContext(ThemeContext);
|
const theme = useContext(ThemeContext);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const [exportAnchor, setExportAnchor] = useState<null | HTMLElement>(null);
|
|
||||||
|
|
||||||
const selectedTitle =
|
|
||||||
conversations.find((c) => c.id === selectedConversation)?.title ||
|
|
||||||
'conversation';
|
|
||||||
|
|
||||||
const handleConversationExport = async (format: ExportFormat) => {
|
|
||||||
setExportAnchor(null);
|
|
||||||
const turns = conversationDetails
|
|
||||||
.filter((d) => d.message.length > 0)
|
|
||||||
.map((d) => ({
|
|
||||||
role: (d.user_created ? 'user' : 'assistant') as 'user' | 'assistant',
|
|
||||||
message: d.message,
|
|
||||||
timestamp: d.created_timestamp,
|
|
||||||
citations: d.citations,
|
|
||||||
}));
|
|
||||||
if (!turns.length) {
|
|
||||||
showToast('Nothing to export yet', 'info');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await exportChat({
|
|
||||||
format,
|
|
||||||
scope: 'conversation',
|
|
||||||
title: selectedTitle,
|
|
||||||
turns,
|
|
||||||
});
|
|
||||||
trackEvent(AnalyticsEvents.MESSAGE_EXPORTED, {
|
|
||||||
format,
|
|
||||||
scope: 'conversation',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
showToast('Export failed', 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePromptRating = (promptId: number | undefined, rating: PromptRating | null) => {
|
|
||||||
if (!promptId) return;
|
|
||||||
const next = conversationDetails.map((detail) => {
|
|
||||||
if (detail.id !== promptId) return detail;
|
|
||||||
return new ConversationPrompt({
|
|
||||||
...detail,
|
|
||||||
feedback: rating
|
|
||||||
? { rating, reason: detail.feedback?.reason, comment: detail.feedback?.comment }
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
conversationRef.current = next;
|
|
||||||
setConversationDetails(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectionBanner = (() => {
|
const connectionBanner = (() => {
|
||||||
if (connectionStatus === ConnectionStatus.CONNECTED || isConnected) {
|
if (connectionStatus === ConnectionStatus.CONNECTED || isConnected) {
|
||||||
@@ -423,7 +404,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const handlePromptSubmit = async (
|
const handlePromptSubmit = async (
|
||||||
{ prompt, file, fileType }: PromptValues,
|
{ prompt, file, fileType, modelName }: PromptValues,
|
||||||
{ resetForm }: any,
|
{ resetForm }: any,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const trimmedPrompt = (prompt ?? "").trim();
|
const trimmedPrompt = (prompt ?? "").trim();
|
||||||
@@ -431,7 +412,6 @@ 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 }),
|
||||||
@@ -440,7 +420,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
|
|
||||||
conversationRef.current = tempConversations;
|
conversationRef.current = tempConversations;
|
||||||
setConversationDetails(tempConversations);
|
setConversationDetails(tempConversations);
|
||||||
sendMessage(trimmedPrompt, selectedConversation, file, fileType, DEFAULT_MODEL_NAME);
|
sendMessage(trimmedPrompt, selectedConversation, file, fileType, modelName);
|
||||||
trackEvent(AnalyticsEvents.MESSAGE_SENT, {
|
trackEvent(AnalyticsEvents.MESSAGE_SENT, {
|
||||||
hasConversation: Boolean(selectedConversation),
|
hasConversation: Boolean(selectedConversation),
|
||||||
hasAttachment: Boolean(file),
|
hasAttachment: Boolean(file),
|
||||||
@@ -450,6 +430,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
prompt: "",
|
prompt: "",
|
||||||
file: null,
|
file: null,
|
||||||
fileType: null,
|
fileType: null,
|
||||||
|
modelName: modelName, // Keep the selected model
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -473,10 +454,15 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<ParticleBackground />
|
<ParticleBackground />
|
||||||
<Header2 onOpenConversations={() => setIsSidebarOpen(true)} />
|
<Header2 />
|
||||||
|
|
||||||
<Overlay $isOpen={isSidebarOpen} onClick={() => setIsSidebarOpen(false)} />
|
<Overlay $isOpen={isSidebarOpen} onClick={() => setIsSidebarOpen(false)} />
|
||||||
|
|
||||||
|
<MobileSidebarToggle onClick={() => setIsSidebarOpen(true)}>
|
||||||
|
<Menu fontSize="small" />
|
||||||
|
<span>Conversations</span>
|
||||||
|
</MobileSidebarToggle>
|
||||||
|
|
||||||
<Sidebar $isOpen={isSidebarOpen}>
|
<Sidebar $isOpen={isSidebarOpen}>
|
||||||
<MobileSidebarHeader>
|
<MobileSidebarHeader>
|
||||||
<span>Conversations</span>
|
<span>Conversations</span>
|
||||||
@@ -530,70 +516,23 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|
||||||
<MainContent>
|
<MainContent>
|
||||||
{conversationDetails.length > 0 && (
|
|
||||||
<ChatToolbar>
|
|
||||||
<Tooltip title="Export conversation">
|
|
||||||
<MuiIconButton
|
|
||||||
aria-label="Export conversation"
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => setExportAnchor(e.currentTarget)}
|
|
||||||
sx={{ color: theme?.colors?.text }}
|
|
||||||
>
|
|
||||||
<FileDownload fontSize="small" />
|
|
||||||
</MuiIconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Menu
|
|
||||||
anchorEl={exportAnchor}
|
|
||||||
open={Boolean(exportAnchor)}
|
|
||||||
onClose={() => setExportAnchor(null)}
|
|
||||||
>
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
['pdf', 'PDF'],
|
|
||||||
['csv', 'CSV'],
|
|
||||||
['xlsx', 'Excel (.xlsx)'],
|
|
||||||
['txt', 'Plain text'],
|
|
||||||
] as [ExportFormat, string][]
|
|
||||||
).map(([format, label]) => (
|
|
||||||
<MenuItem key={format} onClick={() => void handleConversationExport(format)}>
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</ChatToolbar>
|
|
||||||
)}
|
|
||||||
<ChatArea>
|
<ChatArea>
|
||||||
{conversationDetails.length > 0 ? (
|
{conversationDetails.length > 0 ? (
|
||||||
conversationDetails.map((convo_detail, index) => {
|
conversationDetails.map((convo_detail, index) =>
|
||||||
const isLast = index === conversationDetails.length - 1;
|
convo_detail.message.length > 0 ? (
|
||||||
const isLiveStream =
|
|
||||||
!convo_detail.user_created && convo_detail.message.length === 0;
|
|
||||||
const displayMessage = isLiveStream
|
|
||||||
? stateMessage
|
|
||||||
: convo_detail.message;
|
|
||||||
return (
|
|
||||||
<ConversationDetailCard
|
<ConversationDetailCard
|
||||||
key={convo_detail.id || index}
|
message={convo_detail.message}
|
||||||
message={displayMessage}
|
|
||||||
user_created={convo_detail.user_created}
|
user_created={convo_detail.user_created}
|
||||||
promptId={convo_detail.id}
|
key={convo_detail.id || index}
|
||||||
citations={convo_detail.citations}
|
|
||||||
isStreaming={isLiveStream}
|
|
||||||
isLast={isLast}
|
|
||||||
conversationTitle={selectedTitle}
|
|
||||||
createdTimestamp={convo_detail.created_timestamp}
|
|
||||||
initialRating={convo_detail.feedback?.rating ?? null}
|
|
||||||
onRatingChange={(rating) =>
|
|
||||||
updatePromptRating(convo_detail.id, rating)
|
|
||||||
}
|
|
||||||
activityStage={isLiveStream ? activityStage : null}
|
|
||||||
activityLabel={isLiveStream ? activityLabel : null}
|
|
||||||
activityDetail={isLiveStream ? activityDetail : null}
|
|
||||||
activityHistory={isLiveStream ? activityHistory : []}
|
|
||||||
activityInterrupted={isLiveStream && streamInterrupted}
|
|
||||||
/>
|
/>
|
||||||
);
|
) : (
|
||||||
})
|
<ConversationDetailCard
|
||||||
|
message={stateMessage}
|
||||||
|
user_created={convo_detail.user_created}
|
||||||
|
key={convo_detail.id || index}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -615,6 +554,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
prompt: "",
|
prompt: "",
|
||||||
file: null,
|
file: null,
|
||||||
fileType: null,
|
fileType: null,
|
||||||
|
modelName: "FAST",
|
||||||
}}
|
}}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
onSubmit={handlePromptSubmit}
|
onSubmit={handlePromptSubmit}
|
||||||
@@ -665,6 +605,14 @@ 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,11 +7,6 @@ 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();
|
||||||
@@ -48,7 +43,6 @@ const renderCallback = (query: string) => {
|
|||||||
<Route path="/" element={<div>Home</div>} />
|
<Route path="/" element={<div>Home</div>} />
|
||||||
<Route path="/terms_of_service/" element={<div>TOS</div>} />
|
<Route path="/terms_of_service/" element={<div>TOS</div>} />
|
||||||
<Route path="/signin/" element={<div>Sign In Page</div>} />
|
<Route path="/signin/" element={<div>Sign In Page</div>} />
|
||||||
<Route path="/document_storage/" element={<div>Document Storage Page</div>} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</AccountContext.Provider>
|
</AccountContext.Provider>
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
@@ -133,7 +127,7 @@ describe('AuthCallback', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockPost).toHaveBeenCalledWith(
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
'/monetization/checkout/',
|
'/finance/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'),
|
||||||
@@ -142,13 +136,4 @@ describe('AuthCallback', () => {
|
|||||||
expect(assignMock).toHaveBeenCalledWith('https://checkout.stripe.test/session');
|
expect(assignMock).toHaveBeenCalledWith('https://checkout.stripe.test/session');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects drive-link callbacks straight to Documents (#83/#84)', async () => {
|
|
||||||
renderCallback('?drive_connected=1');
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Document Storage Page')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(mockGet).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ 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;
|
||||||
@@ -84,6 +81,14 @@ 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);
|
||||||
@@ -106,13 +111,6 @@ const AuthCallback = (): JSX.Element => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drive-link flows (#83/#84) redirect here already authenticated — just
|
|
||||||
// bounce to Documents with a flag so it can show a success banner.
|
|
||||||
if (searchParams.get('drive_connected') === '1') {
|
|
||||||
navigate('/document_storage/?drive_connected=1', { replace: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = searchParams.get('access');
|
const access = searchParams.get('access');
|
||||||
const refresh = searchParams.get('refresh');
|
const refresh = searchParams.get('refresh');
|
||||||
const needsCheckout = searchParams.get('needs_checkout') === '1';
|
const needsCheckout = searchParams.get('needs_checkout') === '1';
|
||||||
@@ -137,7 +135,6 @@ 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,
|
||||||
@@ -161,27 +158,12 @@ 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('/monetization/checkout/', {
|
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
|
||||||
success_url,
|
success_url,
|
||||||
cancel_url,
|
cancel_url,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { ThemeProvider } from 'styled-components';
|
|
||||||
import DocumentStoragePage from './DocumentStoragePage';
|
|
||||||
import { AuthContext } from '../../contexts/AuthContext';
|
|
||||||
import { AccountContext } from '../../contexts/AccountContext';
|
|
||||||
import { Account } from '../../data';
|
|
||||||
import { resetSubscriptionCache } from '../../hooks/useSubscription';
|
|
||||||
|
|
||||||
const mockGet = jest.fn();
|
|
||||||
const mockPost = jest.fn();
|
|
||||||
const mockPatch = jest.fn();
|
|
||||||
const mockDelete = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('../../../axiosApi', () => ({
|
|
||||||
axiosInstance: {
|
|
||||||
get: (...args: unknown[]) => mockGet(...args),
|
|
||||||
post: (...args: unknown[]) => mockPost(...args),
|
|
||||||
patch: (...args: unknown[]) => mockPatch(...args),
|
|
||||||
delete: (...args: unknown[]) => mockDelete(...args),
|
|
||||||
defaults: { headers: { common: {} as Record<string, string | null> } },
|
|
||||||
},
|
|
||||||
applyAccessToken: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
|
||||||
jest.mock('../../components/Header2/Header2', () => () => null);
|
|
||||||
|
|
||||||
const theme = {
|
|
||||||
main: '#4a90e2',
|
|
||||||
focus: '#224466',
|
|
||||||
darkMode: true,
|
|
||||||
colors: {
|
|
||||||
text: '#ffffff',
|
|
||||||
cardBackground: 'rgba(0,0,0,0.3)',
|
|
||||||
cardBorder: 'rgba(255,255,255,0.1)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const subscriptionResponse = (rag: boolean) => ({
|
|
||||||
data: {
|
|
||||||
plan: {
|
|
||||||
slug: rag ? 'pro' : 'standard',
|
|
||||||
name: rag ? 'Pro' : 'Standard',
|
|
||||||
features: {
|
|
||||||
text_generation: true,
|
|
||||||
image_generation: rag,
|
|
||||||
rag,
|
|
||||||
all_future_features: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 'active',
|
|
||||||
source: 'stripe',
|
|
||||||
needs_checkout: false,
|
|
||||||
stripe_subscription_id: 'sub_1',
|
|
||||||
usage: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockGetByUrl = (overrides: { rag: boolean }) => {
|
|
||||||
mockGet.mockImplementation((url: string) => {
|
|
||||||
if (url === '/monetization/subscription/') {
|
|
||||||
return Promise.resolve(subscriptionResponse(overrides.rag));
|
|
||||||
}
|
|
||||||
if (typeof url === 'string' && url.startsWith('/documents/')) {
|
|
||||||
return Promise.resolve({
|
|
||||||
data: { count: 0, page: 1, page_size: 20, scope: 'personal', results: [] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url === '/drive/connections/') {
|
|
||||||
return Promise.resolve({ data: [] });
|
|
||||||
}
|
|
||||||
return Promise.resolve({ data: [] });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPage = (options?: { isCompanyManager?: boolean; hasCompany?: boolean }) => {
|
|
||||||
const isCompanyManager = options?.isCompanyManager ?? false;
|
|
||||||
const hasCompany = options?.hasCompany ?? false;
|
|
||||||
const account = new Account({
|
|
||||||
email: 'user@example.com',
|
|
||||||
is_company_manager: isCompanyManager,
|
|
||||||
company: hasCompany ? { id: 1, name: 'Acme', state: '', zipcode: '', address: '' } : undefined,
|
|
||||||
});
|
|
||||||
return render(
|
|
||||||
<MemoryRouter initialEntries={['/document_storage/']}>
|
|
||||||
<ThemeProvider theme={theme as never}>
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{
|
|
||||||
authenticated: true,
|
|
||||||
setAuthentication: jest.fn(),
|
|
||||||
needsNewPassword: false,
|
|
||||||
setNeedsNewPassword: jest.fn(),
|
|
||||||
loading: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccountContext.Provider value={{ account, setAccount: jest.fn() }}>
|
|
||||||
<DocumentStoragePage />
|
|
||||||
</AccountContext.Provider>
|
|
||||||
</AuthContext.Provider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('DocumentStoragePage (#81/#82/#83/#84/#93)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockGet.mockReset();
|
|
||||||
mockPost.mockReset();
|
|
||||||
mockPatch.mockReset();
|
|
||||||
mockDelete.mockReset();
|
|
||||||
resetSubscriptionCache();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an upgrade card instead of upload tables when the plan has no RAG', async () => {
|
|
||||||
mockGetByUrl({ rag: false });
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
expect(await screen.findByText('Unlock document storage')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('link', { name: /upgrade in billing/i })).toHaveAttribute(
|
|
||||||
'href',
|
|
||||||
'/account/'
|
|
||||||
);
|
|
||||||
expect(screen.queryByText('Company documents')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Personal documents')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Upload a Document')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows personal documents for users without a company', async () => {
|
|
||||||
mockGetByUrl({ rag: true });
|
|
||||||
|
|
||||||
renderPage({ hasCompany: false });
|
|
||||||
|
|
||||||
expect(await screen.findByText('Personal documents')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Company documents')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('tab', { name: 'Company' })).not.toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.queryByText(/shared with your whole company workspace/i)
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Upload a Document')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Cloud drives')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Company knowledge sources')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows company/personal tabs and company helper copy for company members', async () => {
|
|
||||||
mockGetByUrl({ rag: true });
|
|
||||||
|
|
||||||
renderPage({ hasCompany: true });
|
|
||||||
|
|
||||||
expect(await screen.findByText('Company documents')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('tab', { name: 'Personal' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('tab', { name: 'Company' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/shared with your whole company workspace/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('also shows the company knowledge sources section for company managers (#84)', async () => {
|
|
||||||
mockGetByUrl({ rag: true });
|
|
||||||
|
|
||||||
renderPage({ isCompanyManager: true, hasCompany: true });
|
|
||||||
|
|
||||||
expect(await screen.findByText('Company knowledge sources')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requests documents with workspace/page/search params (#93)', async () => {
|
|
||||||
mockGetByUrl({ rag: true });
|
|
||||||
|
|
||||||
renderPage({ hasCompany: true });
|
|
||||||
|
|
||||||
await screen.findByText('Company documents');
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockGet).toHaveBeenCalledWith(
|
|
||||||
'/documents/',
|
|
||||||
expect.objectContaining({
|
|
||||||
params: expect.objectContaining({
|
|
||||||
workspace: 'company',
|
|
||||||
page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useCallback, useContext, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
|
||||||
import { Document, DocumentType } from "../../data";
|
import { Document, DocumentType } from "../../data";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { axiosInstance } from "../../../axiosApi";
|
import { axiosInstance } from "../../../axiosApi";
|
||||||
import Header2 from "../../components/Header2/Header2";
|
import Header2 from "../../components/Header2/Header2";
|
||||||
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
|
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
|
||||||
import DriveConnectionsSection from "../../components/DriveConnectionsSection/DriveConnectionsSection";
|
|
||||||
import { AccountContext } from "../../contexts/AccountContext";
|
|
||||||
import { useSubscription } from "../../hooks/useSubscription";
|
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
// Styled Components
|
// Styled Components
|
||||||
@@ -20,6 +16,7 @@ const PageContainer = styled.div`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: ${({ theme }) => theme.colors.text};
|
color: ${({ theme }) => theme.colors.text};
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
|
/* background-color: ${({ theme }) => theme.colors.background}; Removed to show particles */
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ContentWrapper = styled.div`
|
const ContentWrapper = styled.div`
|
||||||
@@ -63,72 +60,6 @@ const CardTitle = styled.h2`
|
|||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const TabRow = styled.div`
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TabButton = styled.button<{ $active: boolean }>`
|
|
||||||
background: ${({ $active, theme }) => ($active ? theme.main : 'transparent')};
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: ${({ $active, theme }) => ($active ? '#fff' : theme.colors.text)};
|
|
||||||
padding: 0.55rem 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Toolbar = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SearchInput = styled.input`
|
|
||||||
flex: 1;
|
|
||||||
min-width: 180px;
|
|
||||||
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.4)'};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PaginationRow = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 1rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.8;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PageButton = styled.button`
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
padding: 0.4rem 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledTable = styled.table`
|
const StyledTable = styled.table`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -144,15 +75,6 @@ const Th = styled.th`
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SortableTh = styled(Th)`
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Td = styled.td`
|
const Td = styled.td`
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
@@ -184,26 +106,6 @@ const StyledButton = styled.button`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledButtonLink = styled(Link)`
|
|
||||||
background: ${({ theme }) => theme.main};
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: #fff;
|
|
||||||
padding: 0.8rem 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px ${({ theme }) => theme.main}66;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FileInputLabel = styled.label`
|
const FileInputLabel = styled.label`
|
||||||
background: ${({ theme }) => theme.main};
|
background: ${({ theme }) => theme.main};
|
||||||
border: none;
|
border: none;
|
||||||
@@ -279,177 +181,50 @@ const Checkbox = styled.input`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const HelperNote = styled.p`
|
|
||||||
margin: 1rem 0 0 0;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.65;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const UpgradeText = styled.p`
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.8;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SuccessBanner = styled.div`
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: rgba(76, 175, 80, 0.15);
|
|
||||||
border: 1px solid rgba(76, 175, 80, 0.4);
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DismissButton = styled.button`
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0.7;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const EmptyRow = styled.td`
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
opacity: 0.65;
|
|
||||||
text-align: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export type DocumentWorkspaceScope = 'personal' | 'company';
|
|
||||||
|
|
||||||
type DocumentsListResponse = {
|
|
||||||
count: number;
|
|
||||||
page: number;
|
|
||||||
page_size: number;
|
|
||||||
scope: DocumentWorkspaceScope;
|
|
||||||
results: DocumentType[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type SortField = 'name' | 'created' | 'processed' | 'active';
|
|
||||||
|
|
||||||
type DocumentTableCardProps = {
|
type DocumentTableCardProps = {
|
||||||
scope: DocumentWorkspaceScope;
|
documents: Document[],
|
||||||
showTabs: boolean;
|
setDocuments: React.Dispatch<React.SetStateAction<Document[]>>
|
||||||
onScopeChange: (scope: DocumentWorkspaceScope) => void;
|
}
|
||||||
documents: Document[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
search: string;
|
|
||||||
ordering: string;
|
|
||||||
loading: boolean;
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
onSort: (field: SortField) => void;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
onToggleActive: (id: number, active: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortLabel = (field: SortField, ordering: string): string => {
|
const CompanyDocumentStorageTableCard = ({ documents, setDocuments }: DocumentTableCardProps): JSX.Element => {
|
||||||
const labels: Record<SortField, string> = {
|
|
||||||
name: 'Name',
|
|
||||||
created: 'Date Uploaded',
|
|
||||||
processed: 'Processed',
|
|
||||||
active: 'Active',
|
|
||||||
};
|
|
||||||
if (ordering === field) return `${labels[field]} ↑`;
|
|
||||||
if (ordering === `-${field}`) return `${labels[field]} ↓`;
|
|
||||||
return labels[field];
|
|
||||||
};
|
|
||||||
|
|
||||||
const DocumentStorageTableCard = ({
|
useEffect(() => {
|
||||||
scope,
|
async function getUploadedDocuments() {
|
||||||
showTabs,
|
try {
|
||||||
onScopeChange,
|
const { data, }: AxiosResponse<DocumentType[]> = await axiosInstance.get(`/documents/`);
|
||||||
documents,
|
setDocuments(data.map((item) => new Document({
|
||||||
total,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
search,
|
|
||||||
ordering,
|
|
||||||
loading,
|
|
||||||
onSearchChange,
|
|
||||||
onSort,
|
|
||||||
onPageChange,
|
|
||||||
onToggleActive,
|
|
||||||
}: DocumentTableCardProps): JSX.Element => {
|
|
||||||
const title = scope === 'company' ? 'Company documents' : 'Personal documents';
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
||||||
|
|
||||||
|
id: item.id,
|
||||||
|
name: item.file.replace(/^.*[\\/]/, ''),
|
||||||
|
date_uploaded: item.created.substring(0, 10),
|
||||||
|
active: item.active,
|
||||||
|
processed: item.processed,
|
||||||
|
|
||||||
|
})))
|
||||||
|
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getUploadedDocuments();
|
||||||
|
}, [setDocuments])
|
||||||
return (
|
return (
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>Your documents in the company workspace</CardTitle>
|
||||||
|
|
||||||
{showTabs && (
|
|
||||||
<TabRow role="tablist" aria-label="Document workspace">
|
|
||||||
<TabButton
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={scope === 'personal'}
|
|
||||||
$active={scope === 'personal'}
|
|
||||||
onClick={() => onScopeChange('personal')}
|
|
||||||
>
|
|
||||||
Personal
|
|
||||||
</TabButton>
|
|
||||||
<TabButton
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={scope === 'company'}
|
|
||||||
$active={scope === 'company'}
|
|
||||||
onClick={() => onScopeChange('company')}
|
|
||||||
>
|
|
||||||
Company
|
|
||||||
</TabButton>
|
|
||||||
</TabRow>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Toolbar>
|
|
||||||
<SearchInput
|
|
||||||
type="search"
|
|
||||||
placeholder="Search documents…"
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => onSearchChange(event.target.value)}
|
|
||||||
aria-label="Search documents"
|
|
||||||
/>
|
|
||||||
</Toolbar>
|
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<StyledTable>
|
<StyledTable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortableTh onClick={() => onSort('name')}>{sortLabel('name', ordering)}</SortableTh>
|
<Th>Name</Th>
|
||||||
<SortableTh onClick={() => onSort('created')}>{sortLabel('created', ordering)}</SortableTh>
|
<Th>Date Uploaded</Th>
|
||||||
<SortableTh onClick={() => onSort('processed')}>{sortLabel('processed', ordering)}</SortableTh>
|
<Th>Processed</Th>
|
||||||
<SortableTh onClick={() => onSort('active')}>{sortLabel('active', ordering)}</SortableTh>
|
<Th>Active</Th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{documents.map((doc) => (
|
||||||
<tr>
|
|
||||||
<EmptyRow colSpan={4}>Loading documents…</EmptyRow>
|
|
||||||
</tr>
|
|
||||||
) : documents.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<EmptyRow colSpan={4}>No documents found.</EmptyRow>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
documents.map((doc) => (
|
|
||||||
<tr key={doc.id}>
|
<tr key={doc.id}>
|
||||||
<Td>{doc.name}</Td>
|
<Td>{doc.name}</Td>
|
||||||
<Td>{doc.date_uploaded}</Td>
|
<Td>{doc.date_uploaded}</Td>
|
||||||
@@ -465,80 +240,53 @@ const DocumentStorageTableCard = ({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={doc.active}
|
checked={doc.active}
|
||||||
onChange={(event) => onToggleActive(doc.id, event.target.checked)}
|
disabled={true}
|
||||||
/>
|
/>
|
||||||
<Slider />
|
<Slider />
|
||||||
</ToggleSwitch>
|
</ToggleSwitch>
|
||||||
</Td>
|
</Td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</StyledTable>
|
</StyledTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PaginationRow>
|
|
||||||
<span>
|
|
||||||
{total === 0
|
|
||||||
? '0 documents'
|
|
||||||
: `Showing ${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)} of ${total}`}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
||||||
<PageButton type="button" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
|
|
||||||
Previous
|
|
||||||
</PageButton>
|
|
||||||
<PageButton
|
|
||||||
type="button"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => onPageChange(page + 1)}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</PageButton>
|
|
||||||
</div>
|
|
||||||
</PaginationRow>
|
|
||||||
|
|
||||||
{scope === 'company' && (
|
|
||||||
<HelperNote>
|
|
||||||
These documents are shared with your whole company workspace. Want to search your own
|
|
||||||
files privately? Connect a personal cloud drive in the Cloud drives section below.
|
|
||||||
</HelperNote>
|
|
||||||
)}
|
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
type DocumentUploadCardProps = {
|
const UserDocumentStorageTableCard = (): JSX.Element => {
|
||||||
scope: DocumentWorkspaceScope;
|
return (
|
||||||
onUploaded: () => void | Promise<void>;
|
<GlassCard>
|
||||||
};
|
<CardTitle>Your documents in your personal workspace</CardTitle>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.7)' }}>This will become available shortly</p>
|
||||||
|
</GlassCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DocumentUploadCard = ({ scope, onUploaded }: DocumentUploadCardProps): JSX.Element => {
|
const DocumentUploadCard = (): JSX.Element => {
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [uploading, setUploading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const handleDocumentUpload = async (): Promise<void> => {
|
const handleDocumentUpload = async (): Promise<void> => {
|
||||||
if (!selectedFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
await axiosInstance.post(
|
|
||||||
'/documents/',
|
|
||||||
{ file: selectedFile },
|
|
||||||
{
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
params: { workspace: scope },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
setSelectedFile(null);
|
console.log(selectedFile)
|
||||||
await onUploaded();
|
if (selectedFile) {
|
||||||
} catch (error) {
|
try {
|
||||||
console.log(error);
|
await axiosInstance.post('/documents/', {
|
||||||
} finally {
|
file: selectedFile
|
||||||
setUploading(false);
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO set the documents here
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (event.target.files && event.target.files.length > 0) {
|
if (event.target.files && event.target.files.length > 0) {
|
||||||
@@ -551,213 +299,43 @@ const DocumentUploadCard = ({ scope, onUploaded }: DocumentUploadCardProps): JSX
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
|
||||||
<FileInputLabel>
|
<FileInputLabel>
|
||||||
Select File
|
Select File
|
||||||
<input type="file" hidden onChange={handleFileChange} disabled={uploading} />
|
<input type="file" hidden onChange={handleFileChange} />
|
||||||
</FileInputLabel>
|
</FileInputLabel>
|
||||||
|
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
<>
|
<>
|
||||||
<span style={{ color: '#fff' }}>{selectedFile.name}</span>
|
<span style={{ color: '#fff' }}>{selectedFile.name}</span>
|
||||||
<StyledButton onClick={handleDocumentUpload} disabled={uploading}>
|
<StyledButton onClick={handleDocumentUpload}>
|
||||||
{uploading ? 'Uploading…' : 'Upload'}
|
Upload
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
const DocumentStoragePageInner = (): JSX.Element => {
|
const DocumentStoragePageInner = (): JSX.Element => {
|
||||||
const [documents, setDocuments] = useState<Document[]>([]);
|
const [documents, setDocuments] = useState<Document[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [searchInput, setSearchInput] = useState('');
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [ordering, setOrdering] = useState('-created');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const { account } = useContext(AccountContext);
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [showDriveConnectedBanner, setShowDriveConnectedBanner] = useState(false);
|
|
||||||
|
|
||||||
const hasCompany = Boolean(account?.company);
|
|
||||||
const [scope, setScope] = useState<DocumentWorkspaceScope>(
|
|
||||||
hasCompany ? 'company' : 'personal'
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasCompany && scope === 'company') {
|
|
||||||
setScope('personal');
|
|
||||||
}
|
|
||||||
}, [hasCompany, scope]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handle = window.setTimeout(() => {
|
|
||||||
setSearch(searchInput.trim());
|
|
||||||
setPage(1);
|
|
||||||
}, 300);
|
|
||||||
return () => window.clearTimeout(handle);
|
|
||||||
}, [searchInput]);
|
|
||||||
|
|
||||||
const fetchDocuments = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const { data }: AxiosResponse<DocumentsListResponse> = await axiosInstance.get(
|
|
||||||
`/documents/`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
workspace: scope,
|
|
||||||
page,
|
|
||||||
page_size: PAGE_SIZE,
|
|
||||||
search: search || undefined,
|
|
||||||
ordering,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const results = Array.isArray(data?.results) ? data.results : [];
|
|
||||||
setTotal(typeof data?.count === 'number' ? data.count : results.length);
|
|
||||||
setDocuments(
|
|
||||||
results.map(
|
|
||||||
(item) =>
|
|
||||||
new Document({
|
|
||||||
id: item.id,
|
|
||||||
name: item.file.replace(/^.*[\\/]/, ''),
|
|
||||||
date_uploaded: item.created.substring(0, 10),
|
|
||||||
active: item.active,
|
|
||||||
processed: item.processed,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
setDocuments([]);
|
|
||||||
setTotal(0);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [scope, page, search, ordering]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchDocuments();
|
|
||||||
}, [fetchDocuments]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchParams.get('drive_connected') === '1') {
|
|
||||||
setShowDriveConnectedBanner(true);
|
|
||||||
const next = new URLSearchParams(searchParams);
|
|
||||||
next.delete('drive_connected');
|
|
||||||
setSearchParams(next, { replace: true });
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleActive = useCallback(async (id: number, active: boolean) => {
|
|
||||||
setDocuments((prev) => prev.map((doc) => (doc.id === id ? { ...doc, active } as Document : doc)));
|
|
||||||
try {
|
|
||||||
await axiosInstance.patch(`documents_details/${id}`, { active });
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
setDocuments((prev) => prev.map((doc) => (doc.id === id ? { ...doc, active: !active } as Document : doc)));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
|
||||||
setPage(1);
|
|
||||||
setOrdering((prev) => {
|
|
||||||
if (prev === field) return `-${field}`;
|
|
||||||
if (prev === `-${field}`) return field;
|
|
||||||
return field === 'created' ? '-created' : field;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScopeChange = (next: DocumentWorkspaceScope) => {
|
|
||||||
setScope(next);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showDriveConnectedBanner && (
|
<CompanyDocumentStorageTableCard documents={documents} setDocuments={setDocuments} />
|
||||||
<SuccessBanner>
|
<UserDocumentStorageTableCard />
|
||||||
<span>Drive connected. Choose folders below to include them in your knowledge base.</span>
|
<DocumentUploadCard />
|
||||||
<DismissButton
|
|
||||||
type="button"
|
|
||||||
aria-label="Dismiss"
|
|
||||||
onClick={() => setShowDriveConnectedBanner(false)}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</DismissButton>
|
|
||||||
</SuccessBanner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DocumentStorageTableCard
|
|
||||||
scope={scope}
|
|
||||||
showTabs={hasCompany}
|
|
||||||
onScopeChange={handleScopeChange}
|
|
||||||
documents={documents}
|
|
||||||
total={total}
|
|
||||||
page={page}
|
|
||||||
pageSize={PAGE_SIZE}
|
|
||||||
search={searchInput}
|
|
||||||
ordering={ordering}
|
|
||||||
loading={loading}
|
|
||||||
onSearchChange={setSearchInput}
|
|
||||||
onSort={handleSort}
|
|
||||||
onPageChange={setPage}
|
|
||||||
onToggleActive={handleToggleActive}
|
|
||||||
/>
|
|
||||||
<DocumentUploadCard scope={scope} onUploaded={fetchDocuments} />
|
|
||||||
|
|
||||||
<DriveConnectionsSection
|
|
||||||
kind="personal"
|
|
||||||
title="Cloud drives"
|
|
||||||
description="Connect your personal Google Drive or OneDrive so its files can be searched in chat."
|
|
||||||
connectIntent="link_drive"
|
|
||||||
onSynced={fetchDocuments}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{account?.is_company_manager && (
|
|
||||||
<DriveConnectionsSection
|
|
||||||
kind="company"
|
|
||||||
title="Company knowledge sources"
|
|
||||||
description="Connect a shared Google Shared Drive or Microsoft 365 site so your whole team can search these files in chat."
|
|
||||||
connectIntent="link_company_drive"
|
|
||||||
onSynced={fetchDocuments}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
const DocumentStoragePage = (): JSX.Element => {
|
const DocumentStoragePage = (): JSX.Element => {
|
||||||
const { hasRag, loading } = useSubscription();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<ParticleBackground />
|
<ParticleBackground />
|
||||||
<Header2 />
|
<Header2 />
|
||||||
<ContentWrapper>
|
<ContentWrapper>
|
||||||
{loading ? (
|
|
||||||
<GlassCard>
|
|
||||||
<CardTitle>Loading…</CardTitle>
|
|
||||||
</GlassCard>
|
|
||||||
) : hasRag ? (
|
|
||||||
<DocumentStoragePageInner />
|
<DocumentStoragePageInner />
|
||||||
) : (
|
|
||||||
<GlassCard>
|
|
||||||
<CardTitle>Unlock document storage</CardTitle>
|
|
||||||
<UpgradeText>
|
|
||||||
Document uploads and cloud drive connections are available on plans that include
|
|
||||||
RAG document search. Upgrade your plan to start uploading files and connecting
|
|
||||||
Google Drive or OneDrive.
|
|
||||||
</UpgradeText>
|
|
||||||
<StyledButtonLink to="/account/">Upgrade in Billing</StyledButtonLink>
|
|
||||||
</GlassCard>
|
|
||||||
)}
|
|
||||||
</ContentWrapper>
|
</ContentWrapper>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default DocumentStoragePage;
|
export default DocumentStoragePage;
|
||||||
@@ -8,10 +8,6 @@ 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();
|
||||||
|
|
||||||
@@ -89,7 +85,7 @@ describe('SignIn', () => {
|
|||||||
renderSignIn();
|
renderSignIn();
|
||||||
|
|
||||||
expect(await screen.findByRole('button', { name: 'Continue with Google' })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: 'Continue with Google' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Sign in with Microsoft' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Continue with Microsoft' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides SSO buttons when oauth not configured', async () => {
|
it('hides SSO buttons when oauth not configured', async () => {
|
||||||
@@ -103,7 +99,6 @@ describe('SignIn', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole('button', { name: 'Continue with Google' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'Continue with Google' })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: 'Sign in with Microsoft' })).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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';
|
||||||
@@ -222,7 +221,6 @@ 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,
|
||||||
@@ -242,9 +240,6 @@ 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,11 +8,6 @@ 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();
|
||||||
@@ -139,7 +134,7 @@ describe('SignUp', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockPost).toHaveBeenCalledWith('/monetization/checkout/', {
|
expect(mockPost).toHaveBeenCalledWith('/finance/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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ 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';
|
||||||
@@ -173,6 +170,14 @@ 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);
|
||||||
@@ -208,7 +213,6 @@ 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,
|
||||||
@@ -245,33 +249,16 @@ 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('/monetization/checkout/', {
|
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
|
||||||
success_url,
|
success_url,
|
||||||
cancel_url,
|
cancel_url,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,13 +20,7 @@ const queue: QueuedCall[] = [];
|
|||||||
* | Conversation Created | New chat id assigned over WS |
|
* | Conversation Created | New chat id assigned over WS |
|
||||||
* | Message Sent | User submits prompt (no content) |
|
* | Message Sent | User submits prompt (no content) |
|
||||||
* | ToS Acknowledged | POST acknowledge_tos succeeds |
|
* | ToS Acknowledged | POST acknowledge_tos succeeds |
|
||||||
* | Billing Portal Opened | Account manage / portal CTAs |
|
* | Billing Portal Opened | When #33 portal CTA ships |
|
||||||
* | Subscription Upgrade Started | Upgrade intent (#75) |
|
|
||||||
* | Plan Change Started | Change-plan intent (#75) |
|
|
||||||
* | Subscription Cancel Started | Cancel intent (#75) |
|
|
||||||
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
|
|
||||||
* | 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',
|
||||||
@@ -42,17 +36,6 @@ export const AnalyticsEvents = {
|
|||||||
MESSAGE_SENT: 'Message Sent',
|
MESSAGE_SENT: 'Message Sent',
|
||||||
TOS_ACKNOWLEDGED: 'ToS Acknowledged',
|
TOS_ACKNOWLEDGED: 'ToS Acknowledged',
|
||||||
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
||||||
SUBSCRIPTION_UPGRADE_STARTED: 'Subscription Upgrade Started',
|
|
||||||
PLAN_CHANGE_STARTED: 'Plan Change Started',
|
|
||||||
SUBSCRIPTION_CANCEL_STARTED: 'Subscription Cancel Started',
|
|
||||||
ACCOUNT_DELETE_STARTED: 'Account Delete Started',
|
|
||||||
ACCOUNT_DELETE_SUCCESS: 'Account Delete Success',
|
|
||||||
ACCOUNT_DELETE_FAILED: 'Account Delete Failed',
|
|
||||||
MESSAGE_COPIED: 'Message Copied',
|
|
||||||
MESSAGE_RATED: 'Message Rated',
|
|
||||||
MESSAGE_RATING_REASON: 'Message Rating Reason',
|
|
||||||
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];
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { isRagFeatureNotAllowed, parseChatErrorPayload } from './chatErrors';
|
|
||||||
|
|
||||||
describe('parseChatErrorPayload', () => {
|
|
||||||
it('parses an error-type websocket payload', () => {
|
|
||||||
const raw = JSON.stringify({ type: 'error', code: 'feature_not_allowed', content: 'no rag' });
|
|
||||||
expect(parseChatErrorPayload(raw)).toEqual({
|
|
||||||
type: 'error',
|
|
||||||
code: 'feature_not_allowed',
|
|
||||||
content: 'no rag',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for non-error JSON payloads', () => {
|
|
||||||
expect(parseChatErrorPayload(JSON.stringify({ type: 'text', content: 'hi' }))).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for plain streamed text chunks', () => {
|
|
||||||
expect(parseChatErrorPayload('just a plain chunk of text')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isRagFeatureNotAllowed', () => {
|
|
||||||
it('is false for unrelated error codes', () => {
|
|
||||||
expect(isRagFeatureNotAllowed({ code: 'prompt_quota_exceeded', content: 'slow down' })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false when feature_not_allowed is about a different feature', () => {
|
|
||||||
expect(
|
|
||||||
isRagFeatureNotAllowed({
|
|
||||||
code: 'feature_not_allowed',
|
|
||||||
content: 'Your plan does not include image generation.',
|
|
||||||
details: { feature: 'image_generation' },
|
|
||||||
})
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is true when details.feature mentions rag', () => {
|
|
||||||
expect(
|
|
||||||
isRagFeatureNotAllowed({
|
|
||||||
code: 'feature_not_allowed',
|
|
||||||
content: 'Blocked',
|
|
||||||
details: { feature: 'rag' },
|
|
||||||
})
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is true when the message text mentions rag without structured details', () => {
|
|
||||||
expect(
|
|
||||||
isRagFeatureNotAllowed({
|
|
||||||
code: 'feature_not_allowed',
|
|
||||||
content: 'Your plan does not include RAG document search.',
|
|
||||||
})
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false for a null/undefined payload', () => {
|
|
||||||
expect(isRagFeatureNotAllowed(null)).toBe(false);
|
|
||||||
expect(isRagFeatureNotAllowed(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Helpers for the websocket chat error payloads sent by the backend
|
|
||||||
* (see chat_backend/consumers.py — `{"type": "error", "code": ..., "content": ..., "details": {...}}`).
|
|
||||||
*/
|
|
||||||
export type ChatErrorPayload = {
|
|
||||||
type?: string;
|
|
||||||
code?: string;
|
|
||||||
content?: string;
|
|
||||||
message?: string;
|
|
||||||
details?: Record<string, unknown> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Parses a raw websocket text chunk into an error payload, or null when it isn't one. */
|
|
||||||
export function parseChatErrorPayload(message: string): ChatErrorPayload | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(message);
|
|
||||||
if (parsed && typeof parsed === 'object' && parsed.type === 'error') {
|
|
||||||
return parsed as ChatErrorPayload;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* not JSON — plain streamed text chunk */
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when a `feature_not_allowed` error payload is about the RAG (document search) feature. */
|
|
||||||
export function isRagFeatureNotAllowed(payload: ChatErrorPayload | null | undefined): boolean {
|
|
||||||
if (!payload || payload.code !== 'feature_not_allowed') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const feature = payload.details?.feature;
|
|
||||||
if (typeof feature === 'string' && feature.toLowerCase().includes('rag')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const text = `${payload.content ?? ''} ${payload.message ?? ''}`.toLowerCase();
|
|
||||||
return text.includes('rag');
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { copyTextToClipboard } from './clipboard';
|
|
||||||
|
|
||||||
describe('clipboard', () => {
|
|
||||||
const originalClipboard = navigator.clipboard;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
|
||||||
configurable: true,
|
|
||||||
value: originalClipboard,
|
|
||||||
});
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses navigator.clipboard.writeText when available', async () => {
|
|
||||||
const writeText = jest.fn().mockResolvedValue(undefined);
|
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
|
||||||
configurable: true,
|
|
||||||
value: { writeText },
|
|
||||||
});
|
|
||||||
await copyTextToClipboard('hello');
|
|
||||||
expect(writeText).toHaveBeenCalledWith('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to execCommand when clipboard API fails', async () => {
|
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
writeText: jest.fn().mockRejectedValue(new Error('denied')),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(document, 'execCommand', {
|
|
||||||
configurable: true,
|
|
||||||
value: jest.fn().mockReturnValue(true),
|
|
||||||
});
|
|
||||||
await copyTextToClipboard('fallback text');
|
|
||||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back when clipboard API is missing', async () => {
|
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
|
||||||
configurable: true,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
Object.defineProperty(document, 'execCommand', {
|
|
||||||
configurable: true,
|
|
||||||
value: jest.fn().mockReturnValue(true),
|
|
||||||
});
|
|
||||||
await copyTextToClipboard('legacy');
|
|
||||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* Clipboard helper with execCommand fallback for Capacitor / insecure contexts (#97).
|
|
||||||
*/
|
|
||||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// fall through to legacy path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fallbackCopyText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fallbackCopyText(text: string): void {
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.setAttribute('readonly', '');
|
|
||||||
textarea.style.position = 'fixed';
|
|
||||||
textarea.style.top = '0';
|
|
||||||
textarea.style.left = '0';
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.focus();
|
|
||||||
textarea.select();
|
|
||||||
textarea.setSelectionRange(0, textarea.value.length);
|
|
||||||
const ok = document.execCommand('copy');
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
if (!ok) {
|
|
||||||
throw new Error('Copy command failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* Client-side file download. Uses blob URL + anchor click.
|
|
||||||
* Capacitor Android WebView: plain downloads often work for blob URLs;
|
|
||||||
* if not, consider @capacitor/filesystem + Share (#97).
|
|
||||||
*/
|
|
||||||
export function downloadBlob(blob: Blob, filename: string): void {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
try {
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
anchor.rel = 'noopener';
|
|
||||||
anchor.style.display = 'none';
|
|
||||||
document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
document.body.removeChild(anchor);
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function downloadText(text: string, filename: string, mime = 'text/plain;charset=utf-8'): void {
|
|
||||||
downloadBlob(new Blob([text], { type: mime }), filename);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import {
|
|
||||||
driveConnectUrl,
|
|
||||||
driveSyncProgressPercent,
|
|
||||||
formatDriveSyncError,
|
|
||||||
parseResourceIdsInput,
|
|
||||||
} from './drive';
|
|
||||||
|
|
||||||
describe('driveSyncProgressPercent', () => {
|
|
||||||
it('returns null when total is unknown', () => {
|
|
||||||
expect(driveSyncProgressPercent({ id: 1, provider: 'google', sync_total: 0 })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computes rounded percent from processed/total', () => {
|
|
||||||
expect(
|
|
||||||
driveSyncProgressPercent({
|
|
||||||
id: 1,
|
|
||||||
provider: 'google',
|
|
||||||
sync_total: 4,
|
|
||||||
sync_processed: 1,
|
|
||||||
})
|
|
||||||
).toBe(25);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseResourceIdsInput', () => {
|
|
||||||
it('splits comma and newline separated ids and trims whitespace', () => {
|
|
||||||
expect(parseResourceIdsInput('abc, def\nghi ,, ')).toEqual(['abc', 'def', 'ghi']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an empty array for blank input', () => {
|
|
||||||
expect(parseResourceIdsInput(' ')).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatDriveSyncError', () => {
|
|
||||||
it('returns a fallback when empty', () => {
|
|
||||||
expect(formatDriveSyncError('')).toMatch(/Drive sync failed/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('truncates long provider error payloads', () => {
|
|
||||||
const long = 'x'.repeat(400);
|
|
||||||
const formatted = formatDriveSyncError(long, 50);
|
|
||||||
expect(formatted.length).toBeLessThanOrEqual(51);
|
|
||||||
expect(formatted.endsWith('…')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('driveConnectUrl', () => {
|
|
||||||
const originalEnv = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.REACT_APP_BACKEND_REST_API_BASE_URL = 'https://api.example.com/';
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
process.env.REACT_APP_BACKEND_REST_API_BASE_URL = originalEnv;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds a personal drive-link url', () => {
|
|
||||||
expect(driveConnectUrl('google', 'link_drive')).toBe(
|
|
||||||
'https://api.example.com/auth/oauth/google/start/?intent=link_drive'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds a company drive-link url', () => {
|
|
||||||
expect(driveConnectUrl('microsoft', 'link_company_drive')).toBe(
|
|
||||||
'https://api.example.com/auth/oauth/microsoft/start/?intent=link_company_drive'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { axiosInstance } from '../../axiosApi';
|
|
||||||
import { oauthStartUrl } from '../auth/sso';
|
|
||||||
|
|
||||||
export type DriveProvider = 'google' | 'microsoft';
|
|
||||||
|
|
||||||
export type DriveConnectionKind = 'personal' | 'company';
|
|
||||||
|
|
||||||
/** intent query param sent to /auth/oauth/:provider/start/ for drive-linking flows (#83/#84). */
|
|
||||||
export type DriveConnectIntent = 'link_drive' | 'link_company_drive';
|
|
||||||
|
|
||||||
export type DriveSyncStatus = 'ok' | 'error' | 'pending' | 'never';
|
|
||||||
|
|
||||||
export type DriveConnectionType = {
|
|
||||||
id: number;
|
|
||||||
provider: DriveProvider;
|
|
||||||
kind?: DriveConnectionKind;
|
|
||||||
is_active?: boolean;
|
|
||||||
external_account_email?: string | null;
|
|
||||||
selected_resource_ids?: string[];
|
|
||||||
selected_resource_labels?: string[];
|
|
||||||
last_sync_at?: string | null;
|
|
||||||
last_sync_status?: DriveSyncStatus | string | null;
|
|
||||||
last_sync_error?: string;
|
|
||||||
sync_total?: number;
|
|
||||||
sync_processed?: number;
|
|
||||||
sync_added?: number;
|
|
||||||
sync_updated?: number;
|
|
||||||
sync_failed?: number;
|
|
||||||
created?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DriveSyncEnqueueResponse = {
|
|
||||||
queued?: boolean;
|
|
||||||
connection: DriveConnectionType;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchDriveConnections(): Promise<DriveConnectionType[]> {
|
|
||||||
const { data } = await axiosInstance.get<DriveConnectionType[]>('/drive/connections/');
|
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disconnectDriveConnection(connectionId: number): Promise<void> {
|
|
||||||
await axiosInstance.delete(`/drive/connections/${connectionId}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DriveResourceSelection = {
|
|
||||||
resource_ids: string[];
|
|
||||||
resource_labels: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function saveDriveResourceSelection(
|
|
||||||
connectionId: number,
|
|
||||||
selection: DriveResourceSelection
|
|
||||||
): Promise<DriveConnectionType> {
|
|
||||||
const { data } = await axiosInstance.post<DriveConnectionType>(
|
|
||||||
`/drive/connections/${connectionId}/resources/`,
|
|
||||||
selection
|
|
||||||
);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enqueue a Drive sync (#57/#90). Returns quickly with pending status. */
|
|
||||||
export async function syncDriveConnection(
|
|
||||||
connectionId: number
|
|
||||||
): Promise<DriveSyncEnqueueResponse> {
|
|
||||||
const { data } = await axiosInstance.post<DriveSyncEnqueueResponse>(
|
|
||||||
`/drive/connections/${connectionId}/sync/`
|
|
||||||
);
|
|
||||||
if (!data?.connection) {
|
|
||||||
throw new Error('Drive sync did not return a connection.');
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SYNC_POLL_MS = 1500;
|
|
||||||
const DEFAULT_SYNC_TIMEOUT_MS = 120_000;
|
|
||||||
|
|
||||||
/** Poll connections until the target leaves ``pending`` (or timeout). */
|
|
||||||
export async function waitForDriveSyncSettlement(
|
|
||||||
connectionId: number,
|
|
||||||
options?: {
|
|
||||||
intervalMs?: number;
|
|
||||||
timeoutMs?: number;
|
|
||||||
onProgress?: (connection: DriveConnectionType) => void;
|
|
||||||
}
|
|
||||||
): Promise<DriveConnectionType> {
|
|
||||||
const intervalMs = options?.intervalMs ?? DEFAULT_SYNC_POLL_MS;
|
|
||||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS;
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const connections = await fetchDriveConnections();
|
|
||||||
const connection = connections.find((item) => item.id === connectionId);
|
|
||||||
if (!connection) {
|
|
||||||
throw new Error('Drive connection disappeared while syncing.');
|
|
||||||
}
|
|
||||||
options?.onProgress?.(connection);
|
|
||||||
if (connection.last_sync_status !== 'pending') {
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
||||||
}
|
|
||||||
throw new Error('Drive sync timed out. Check status and try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Percent complete when total is known; otherwise null (indeterminate). */
|
|
||||||
export function driveSyncProgressPercent(connection: DriveConnectionType): number | null {
|
|
||||||
const total = connection.sync_total ?? 0;
|
|
||||||
if (total <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const processed = Math.min(connection.sync_processed ?? 0, total);
|
|
||||||
return Math.round((processed / total) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shorten long provider JSON error blobs for toasts. */
|
|
||||||
export function formatDriveSyncError(raw?: string | null, maxLen = 280): string {
|
|
||||||
const text = (raw || '').trim();
|
|
||||||
if (!text) {
|
|
||||||
return 'Drive sync failed. Try again or check provider API settings.';
|
|
||||||
}
|
|
||||||
if (text.length <= maxLen) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
return `${text.slice(0, maxLen).trim()}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Absolute backend OAuth start URL for a drive-link flow (personal or company). */
|
|
||||||
export function driveConnectUrl(provider: DriveProvider, intent: DriveConnectIntent): string {
|
|
||||||
return oauthStartUrl(provider, intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begin Drive link: call start with Bearer JWT, then navigate to IdP authorize URL.
|
|
||||||
* Full-page assign alone cannot send Authorization, so the backend returns JSON.
|
|
||||||
*/
|
|
||||||
export async function connectDrive(
|
|
||||||
provider: DriveProvider,
|
|
||||||
intent: DriveConnectIntent
|
|
||||||
): Promise<void> {
|
|
||||||
const { data } = await axiosInstance.get<{ authorize_url: string }>(
|
|
||||||
`/auth/oauth/${provider}/start/`,
|
|
||||||
{
|
|
||||||
params: { intent, response: 'json' },
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!data?.authorize_url) {
|
|
||||||
throw new Error('Drive connect did not return an authorize URL.');
|
|
||||||
}
|
|
||||||
window.location.assign(data.authorize_url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Splits a comma/newline separated textarea/input value into trimmed, non-empty resource ids. */
|
|
||||||
export function parseResourceIdsInput(raw: string): string[] {
|
|
||||||
return raw
|
|
||||||
.split(/[,\n]/)
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import {
|
|
||||||
buildExportFilename,
|
|
||||||
extractMarkdownTables,
|
|
||||||
parseMarkdownBlocks,
|
|
||||||
sanitizeFilenamePart,
|
|
||||||
stripMarkdown,
|
|
||||||
} from './markdownUtils';
|
|
||||||
import { selectTabularStrategy } from './exportChat';
|
|
||||||
|
|
||||||
describe('export markdown utils', () => {
|
|
||||||
it('sanitises filenames with title + ISO date', () => {
|
|
||||||
expect(sanitizeFilenamePart('Hesychia Taylor Swift Question!')).toBe(
|
|
||||||
'hesychia-taylor-swift-question',
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
buildExportFilename('Hesychia Taylor Swift Question', 'pdf', new Date('2026-08-02T12:00:00Z')),
|
|
||||||
).toBe('hesychia-taylor-swift-question-2026-08-02.pdf');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('strips markdown for plain text', () => {
|
|
||||||
expect(stripMarkdown('# Hello\n\n**world**')).toContain('Hello');
|
|
||||||
expect(stripMarkdown('# Hello\n\n**world**')).not.toContain('**');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses headings, lists, tables, and code', () => {
|
|
||||||
const blocks = parseMarkdownBlocks(
|
|
||||||
[
|
|
||||||
'# Title',
|
|
||||||
'',
|
|
||||||
'- a',
|
|
||||||
'- b',
|
|
||||||
'',
|
|
||||||
'```js',
|
|
||||||
'console.log(1)',
|
|
||||||
'```',
|
|
||||||
'',
|
|
||||||
'| A | B |',
|
|
||||||
'| --- | --- |',
|
|
||||||
'| 1 | 2 |',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
expect(blocks.find((b) => b.type === 'heading')).toMatchObject({
|
|
||||||
level: 1,
|
|
||||||
text: 'Title',
|
|
||||||
});
|
|
||||||
expect(blocks.find((b) => b.type === 'list')).toMatchObject({
|
|
||||||
ordered: false,
|
|
||||||
items: ['a', 'b'],
|
|
||||||
});
|
|
||||||
expect(blocks.find((b) => b.type === 'code')).toMatchObject({
|
|
||||||
language: 'js',
|
|
||||||
text: 'console.log(1)',
|
|
||||||
});
|
|
||||||
expect(blocks.find((b) => b.type === 'table')).toMatchObject({
|
|
||||||
headers: ['A', 'B'],
|
|
||||||
rows: [['1', '2']],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('selects tabular strategy per CSV/XLSX rules', () => {
|
|
||||||
const withTable = '| A | B |\n| --- | --- |\n| 1 | 2 |';
|
|
||||||
expect(selectTabularStrategy('message', withTable)).toBe('tables');
|
|
||||||
expect(selectTabularStrategy('message', 'no table here')).toBe('turns');
|
|
||||||
expect(selectTabularStrategy('conversation', withTable)).toBe('turns');
|
|
||||||
expect(extractMarkdownTables(withTable)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import { downloadBlob, downloadText } from '../downloadFile';
|
|
||||||
import { Citation } from '../wsFrames';
|
|
||||||
import {
|
|
||||||
buildExportFilename,
|
|
||||||
extractMarkdownTables,
|
|
||||||
parseMarkdownBlocks,
|
|
||||||
stripMarkdown,
|
|
||||||
type MdBlock,
|
|
||||||
} from './markdownUtils';
|
|
||||||
|
|
||||||
export type ExportFormat = 'pdf' | 'csv' | 'xlsx' | 'txt';
|
|
||||||
|
|
||||||
export type ExportTurn = {
|
|
||||||
role: 'user' | 'assistant';
|
|
||||||
message: string;
|
|
||||||
timestamp?: string | Date | null;
|
|
||||||
citations?: Citation[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ExportScope = 'message' | 'conversation';
|
|
||||||
|
|
||||||
export type ExportOptions = {
|
|
||||||
format: ExportFormat;
|
|
||||||
scope: ExportScope;
|
|
||||||
title: string;
|
|
||||||
turns: ExportTurn[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function yieldToUi(): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTs(value?: string | Date | null): string {
|
|
||||||
if (!value) return '';
|
|
||||||
try {
|
|
||||||
return new Date(value).toISOString();
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function roleLabel(role: ExportTurn['role']): string {
|
|
||||||
return role === 'user' ? 'User' : 'Assistant';
|
|
||||||
}
|
|
||||||
|
|
||||||
function citationFootnotes(citations?: Citation[]): string {
|
|
||||||
if (!citations?.length) return '';
|
|
||||||
return citations
|
|
||||||
.map((c) => `[${c.index}] ${c.title}${c.url ? ` — ${c.url}` : ''}`)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportChat(options: ExportOptions): Promise<void> {
|
|
||||||
const { format, scope, title, turns } = options;
|
|
||||||
const filename = buildExportFilename(title, format);
|
|
||||||
const exportedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
// Yield so large conversations don't freeze the main thread (#97).
|
|
||||||
await yieldToUi();
|
|
||||||
|
|
||||||
switch (format) {
|
|
||||||
case 'txt':
|
|
||||||
downloadText(buildTxt(title, turns, scope), filename);
|
|
||||||
break;
|
|
||||||
case 'csv':
|
|
||||||
await exportCsv(title, turns, scope, filename);
|
|
||||||
break;
|
|
||||||
case 'xlsx':
|
|
||||||
await exportXlsx(title, turns, scope, filename);
|
|
||||||
break;
|
|
||||||
case 'pdf':
|
|
||||||
await exportPdf(title, turns, exportedAt, filename);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported export format: ${format}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTxt(title: string, turns: ExportTurn[], scope: ExportScope): string {
|
|
||||||
if (scope === 'message' && turns.length === 1) {
|
|
||||||
const turn = turns[0];
|
|
||||||
const body = stripMarkdown(turn.message);
|
|
||||||
const notes = citationFootnotes(turn.citations);
|
|
||||||
return notes ? `${body}\n\nSources:\n${notes}` : body;
|
|
||||||
}
|
|
||||||
const parts = [`# ${title}`, ''];
|
|
||||||
for (const turn of turns) {
|
|
||||||
const ts = formatTs(turn.timestamp);
|
|
||||||
parts.push(`[${roleLabel(turn.role)}${ts ? ` · ${ts}` : ''}]`);
|
|
||||||
parts.push(stripMarkdown(turn.message));
|
|
||||||
const notes = citationFootnotes(turn.citations);
|
|
||||||
if (notes) {
|
|
||||||
parts.push('Sources:');
|
|
||||||
parts.push(notes);
|
|
||||||
}
|
|
||||||
parts.push('');
|
|
||||||
}
|
|
||||||
return parts.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function turnsAsRows(turns: ExportTurn[]): string[][] {
|
|
||||||
const header = ['timestamp', 'role', 'message', 'citations'];
|
|
||||||
const rows = turns.map((turn) => [
|
|
||||||
formatTs(turn.timestamp),
|
|
||||||
roleLabel(turn.role),
|
|
||||||
stripMarkdown(turn.message),
|
|
||||||
citationFootnotes(turn.citations).replace(/\n/g, ' | '),
|
|
||||||
]);
|
|
||||||
return [header, ...rows];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportCsv(
|
|
||||||
_title: string,
|
|
||||||
turns: ExportTurn[],
|
|
||||||
scope: ExportScope,
|
|
||||||
filename: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const Papa = (await import('papaparse')).default;
|
|
||||||
let matrix: string[][];
|
|
||||||
|
|
||||||
if (scope === 'message' && turns.length === 1) {
|
|
||||||
const tables = extractMarkdownTables(turns[0].message);
|
|
||||||
if (tables.length > 0) {
|
|
||||||
const table = tables[0];
|
|
||||||
matrix = [table.headers, ...table.rows];
|
|
||||||
} else {
|
|
||||||
matrix = turnsAsRows(turns);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
matrix = turnsAsRows(turns);
|
|
||||||
}
|
|
||||||
|
|
||||||
const csv = Papa.unparse(matrix);
|
|
||||||
downloadText(csv, filename, 'text/csv;charset=utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportXlsx(
|
|
||||||
_title: string,
|
|
||||||
turns: ExportTurn[],
|
|
||||||
scope: ExportScope,
|
|
||||||
filename: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const XLSX = await import('xlsx');
|
|
||||||
const workbook = XLSX.utils.book_new();
|
|
||||||
|
|
||||||
if (scope === 'message' && turns.length === 1) {
|
|
||||||
const tables = extractMarkdownTables(turns[0].message);
|
|
||||||
if (tables.length > 0) {
|
|
||||||
tables.forEach((table, idx) => {
|
|
||||||
const sheet = XLSX.utils.aoa_to_sheet([table.headers, ...table.rows]);
|
|
||||||
XLSX.utils.book_append_sheet(workbook, sheet, `Table ${idx + 1}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const sheet = XLSX.utils.aoa_to_sheet(turnsAsRows(turns));
|
|
||||||
XLSX.utils.book_append_sheet(workbook, sheet, 'Messages');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const sheet = XLSX.utils.aoa_to_sheet(turnsAsRows(turns));
|
|
||||||
XLSX.utils.book_append_sheet(workbook, sheet, 'Messages');
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
|
||||||
downloadBlob(
|
|
||||||
new Blob([arrayBuffer], {
|
|
||||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
}),
|
|
||||||
filename,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function blocksToPdfContent(blocks: MdBlock[]): unknown[] {
|
|
||||||
const content: unknown[] = [];
|
|
||||||
for (const block of blocks) {
|
|
||||||
switch (block.type) {
|
|
||||||
case 'heading':
|
|
||||||
content.push({
|
|
||||||
text: block.text,
|
|
||||||
style: `h${Math.min(block.level, 3)}`,
|
|
||||||
margin: [0, 8, 0, 4],
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'paragraph':
|
|
||||||
content.push({ text: block.text, margin: [0, 2, 0, 6] });
|
|
||||||
break;
|
|
||||||
case 'code':
|
|
||||||
content.push({
|
|
||||||
text: block.text,
|
|
||||||
fontSize: 9,
|
|
||||||
preserveLeadingSpaces: true,
|
|
||||||
margin: [0, 4, 0, 8],
|
|
||||||
background: '#f5f5f5',
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'list':
|
|
||||||
content.push(
|
|
||||||
block.ordered
|
|
||||||
? { ol: block.items, margin: [0, 2, 0, 6] }
|
|
||||||
: { ul: block.items, margin: [0, 2, 0, 6] },
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'table':
|
|
||||||
content.push({
|
|
||||||
table: {
|
|
||||||
headerRows: 1,
|
|
||||||
widths: block.headers.map(() => '*'),
|
|
||||||
body: [
|
|
||||||
block.headers.map((h) => ({ text: h, bold: true })),
|
|
||||||
...block.rows.map((row) =>
|
|
||||||
block.headers.map((_, col) => row[col] ?? ''),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
margin: [0, 4, 0, 8],
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportPdf(
|
|
||||||
title: string,
|
|
||||||
turns: ExportTurn[],
|
|
||||||
exportedAt: string,
|
|
||||||
filename: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const pdfMakeModule = await import('pdfmake/build/pdfmake');
|
|
||||||
const pdfFonts = await import('pdfmake/build/vfs_fonts');
|
|
||||||
const pdfMake = pdfMakeModule.default || pdfMakeModule;
|
|
||||||
// vfs_fonts may export vfs on default or as pdfMake.vfs
|
|
||||||
const vfs =
|
|
||||||
(pdfFonts as { pdfMake?: { vfs?: unknown }; default?: { pdfMake?: { vfs?: unknown } } })
|
|
||||||
.pdfMake?.vfs ||
|
|
||||||
(pdfFonts as { default?: { pdfMake?: { vfs?: unknown } } }).default?.pdfMake?.vfs ||
|
|
||||||
(pdfFonts as { default?: unknown }).default;
|
|
||||||
if (vfs) {
|
|
||||||
(pdfMake as { vfs?: unknown }).vfs = vfs;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content: unknown[] = [
|
|
||||||
{ text: title, style: 'title' },
|
|
||||||
{ text: `Exported ${exportedAt}`, style: 'meta', margin: [0, 0, 0, 16] },
|
|
||||||
];
|
|
||||||
|
|
||||||
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) {
|
|
||||||
content.push({
|
|
||||||
text: `${roleLabel(turn.role)}${turn.timestamp ? ` · ${formatTs(turn.timestamp)}` : ''}`,
|
|
||||||
style: 'role',
|
|
||||||
margin: [0, 12, 0, 4],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
content.push(...blocksToPdfContent(parseMarkdownBlocks(turn.message)));
|
|
||||||
if (turn.citations?.length) {
|
|
||||||
content.push({ text: 'Sources', style: 'h3', margin: [0, 8, 0, 4] });
|
|
||||||
content.push({
|
|
||||||
ol: turn.citations
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.index - b.index)
|
|
||||||
.map((c) => `${c.title}${c.url ? ` (${c.url})` : ''}`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const docDefinition = {
|
|
||||||
content,
|
|
||||||
styles: {
|
|
||||||
title: { fontSize: 18, bold: true },
|
|
||||||
meta: { fontSize: 9, color: '#666666' },
|
|
||||||
role: { fontSize: 11, bold: true, color: '#333333' },
|
|
||||||
h1: { fontSize: 16, bold: true },
|
|
||||||
h2: { fontSize: 14, bold: true },
|
|
||||||
h3: { fontSize: 12, bold: true },
|
|
||||||
},
|
|
||||||
defaultStyle: { fontSize: 11 },
|
|
||||||
header: {
|
|
||||||
text: `${title} · ${exportedAt}`,
|
|
||||||
fontSize: 8,
|
|
||||||
color: '#888888',
|
|
||||||
margin: [40, 20, 40, 0] as [number, number, number, number],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
// pdfmake typings vary across 0.2/0.3 builds; keep runtime call flexible.
|
|
||||||
const pdf = (pdfMake as { createPdf: (def: unknown) => { getBlob: (cb: (blob: Blob) => void) => void } }).createPdf(
|
|
||||||
docDefinition,
|
|
||||||
);
|
|
||||||
pdf.getBlob((blob: Blob) => {
|
|
||||||
downloadBlob(blob, filename);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decide CSV/XLSX matrix strategy — exported for unit tests. */
|
|
||||||
export function selectTabularStrategy(
|
|
||||||
scope: ExportScope,
|
|
||||||
messageMarkdown: string,
|
|
||||||
): 'tables' | 'turns' {
|
|
||||||
if (scope === 'conversation') return 'turns';
|
|
||||||
return extractMarkdownTables(messageMarkdown).length > 0 ? 'tables' : 'turns';
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
export function sanitizeFilenamePart(value: string): string {
|
|
||||||
return value
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 80) || 'conversation';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildExportFilename(
|
|
||||||
conversationTitle: string,
|
|
||||||
extension: string,
|
|
||||||
date = new Date(),
|
|
||||||
): string {
|
|
||||||
const day = date.toISOString().slice(0, 10);
|
|
||||||
const base = sanitizeFilenamePart(conversationTitle || 'conversation');
|
|
||||||
const ext = extension.replace(/^\./, '');
|
|
||||||
return `${base}-${day}.${ext}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Strip markdown to plain text for TXT / tabular message cells. */
|
|
||||||
export function stripMarkdown(markdown: string): string {
|
|
||||||
return markdown
|
|
||||||
.replace(/```[\s\S]*?```/g, (block) =>
|
|
||||||
block.replace(/```\w*\n?/, '').replace(/```$/, ''),
|
|
||||||
)
|
|
||||||
.replace(/!\[[^\]]*]\([^)]+\)/g, '')
|
|
||||||
.replace(/\[([^\]]+)]\([^)]+\)/g, '$1')
|
|
||||||
.replace(/^#{1,6}\s+/gm, '')
|
|
||||||
.replace(/^\s*[-*+]\s+/gm, '')
|
|
||||||
.replace(/^\s*\d+\.\s+/gm, '')
|
|
||||||
.replace(/[*_~`]+/g, '')
|
|
||||||
.replace(/\n{3,}/g, '\n\n')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MdBlock =
|
|
||||||
| { type: 'heading'; level: number; text: string }
|
|
||||||
| { type: 'paragraph'; text: string }
|
|
||||||
| { type: 'code'; language: string; text: string }
|
|
||||||
| { type: 'list'; ordered: boolean; items: string[] }
|
|
||||||
| { type: 'table'; headers: string[]; rows: string[][] };
|
|
||||||
|
|
||||||
/** Lightweight markdown → block AST for PDF/DOCX exporters. */
|
|
||||||
export function parseMarkdownBlocks(markdown: string): MdBlock[] {
|
|
||||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
|
||||||
const blocks: MdBlock[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < lines.length) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
if (/^```/.test(line)) {
|
|
||||||
const language = line.replace(/^```/, '').trim();
|
|
||||||
const body: string[] = [];
|
|
||||||
i += 1;
|
|
||||||
while (i < lines.length && !/^```/.test(lines[i])) {
|
|
||||||
body.push(lines[i]);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
blocks.push({ type: 'code', language, text: body.join('\n') });
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
||||||
if (heading) {
|
|
||||||
blocks.push({
|
|
||||||
type: 'heading',
|
|
||||||
level: heading[1].length,
|
|
||||||
text: heading[2].trim(),
|
|
||||||
});
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^\|.+\|$/.test(line.trim()) && i + 1 < lines.length && /^\|?\s*[-:| ]+\|?$/.test(lines[i + 1].trim())) {
|
|
||||||
const headers = splitTableRow(line);
|
|
||||||
i += 2;
|
|
||||||
const rows: string[][] = [];
|
|
||||||
while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) {
|
|
||||||
rows.push(splitTableRow(lines[i]));
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
blocks.push({ type: 'table', headers, rows });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^\s*[-*+]\s+/.test(line) || /^\s*\d+\.\s+/.test(line)) {
|
|
||||||
const ordered = /^\s*\d+\.\s+/.test(line);
|
|
||||||
const items: string[] = [];
|
|
||||||
while (
|
|
||||||
i < lines.length &&
|
|
||||||
(ordered ? /^\s*\d+\.\s+/.test(lines[i]) : /^\s*[-*+]\s+/.test(lines[i]))
|
|
||||||
) {
|
|
||||||
items.push(lines[i].replace(/^\s*([-*+]|\d+\.)\s+/, '').trim());
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
blocks.push({ type: 'list', ordered, items });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!line.trim()) {
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const para: string[] = [];
|
|
||||||
while (i < lines.length && lines[i].trim() && !/^```/.test(lines[i]) && !/^(#{1,6})\s+/.test(lines[i])) {
|
|
||||||
if (/^\|.+\|$/.test(lines[i].trim())) break;
|
|
||||||
if (/^\s*[-*+]\s+/.test(lines[i]) || /^\s*\d+\.\s+/.test(lines[i])) break;
|
|
||||||
para.push(lines[i]);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
blocks.push({ type: 'paragraph', text: para.join(' ').trim() });
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitTableRow(line: string): string[] {
|
|
||||||
return line
|
|
||||||
.trim()
|
|
||||||
.replace(/^\|/, '')
|
|
||||||
.replace(/\|$/, '')
|
|
||||||
.split('|')
|
|
||||||
.map((cell) => cell.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extract markdown tables from a single message (for CSV/XLSX tabular rules). */
|
|
||||||
export function extractMarkdownTables(markdown: string): { headers: string[]; rows: string[][] }[] {
|
|
||||||
return parseMarkdownBlocks(markdown)
|
|
||||||
.filter((b): b is Extract<MdBlock, { type: 'table' }> => b.type === 'table')
|
|
||||||
.map(({ headers, rows }) => ({ headers, rows }));
|
|
||||||
}
|
|
||||||
@@ -3,51 +3,9 @@ import {
|
|||||||
formatMoneyCents,
|
formatMoneyCents,
|
||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
humanizeStatus,
|
humanizeStatus,
|
||||||
invoiceProviderLabel,
|
|
||||||
isComplimentarySubscription,
|
|
||||||
isStoreSubscription,
|
|
||||||
isStripeSubscription,
|
|
||||||
pickPrimaryInvoice,
|
pickPrimaryInvoice,
|
||||||
planAllowsRag,
|
|
||||||
} from './finance';
|
} from './finance';
|
||||||
import type { FinanceInvoice, SubscriptionMe, SubscriptionPlanInfo } from './finance';
|
import type { FinanceInvoice } from './finance';
|
||||||
|
|
||||||
const basePlan = (overrides: Partial<SubscriptionPlanInfo> = {}): SubscriptionPlanInfo => ({
|
|
||||||
slug: 'standard',
|
|
||||||
name: 'Standard',
|
|
||||||
description: '',
|
|
||||||
price_cents: 999,
|
|
||||||
currency: 'usd',
|
|
||||||
interval: 'month',
|
|
||||||
is_public: true,
|
|
||||||
is_selectable: true,
|
|
||||||
features: {
|
|
||||||
text_generation: true,
|
|
||||||
image_generation: false,
|
|
||||||
rag: false,
|
|
||||||
all_future_features: false,
|
|
||||||
},
|
|
||||||
prompt_quota_per_window: 100,
|
|
||||||
prompt_window_hours: 6,
|
|
||||||
monthly_token_quota: null,
|
|
||||||
sort_order: 1,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const baseUsage: SubscriptionMe['usage'] = {
|
|
||||||
prompts_in_window: 0,
|
|
||||||
prompt_quota: null,
|
|
||||||
prompts_remaining: null,
|
|
||||||
window_hours: 6,
|
|
||||||
tokens_in_period: null,
|
|
||||||
tokens_out_period: null,
|
|
||||||
tokens_total_period: null,
|
|
||||||
turns_missing_token_usage: 0,
|
|
||||||
monthly_token_quota: null,
|
|
||||||
tokens_remaining: null,
|
|
||||||
period_start: null,
|
|
||||||
period_end: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseInvoice = (overrides: Partial<FinanceInvoice> = {}): FinanceInvoice => ({
|
const baseInvoice = (overrides: Partial<FinanceInvoice> = {}): FinanceInvoice => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -96,90 +54,8 @@ describe('finance helpers', () => {
|
|||||||
expect(pickPrimaryInvoice(invoices)?.id).toBe(2);
|
expect(pickPrimaryInvoice(invoices)?.id).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects portal access from paid Stripe invoices only', () => {
|
it('detects portal access from paid or subscribed invoices', () => {
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('planAllowsRag', () => {
|
|
||||||
it('is false when there is no plan/subscription', () => {
|
|
||||||
expect(planAllowsRag(null)).toBe(false);
|
|
||||||
expect(planAllowsRag(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false when the rag feature flag is off', () => {
|
|
||||||
expect(planAllowsRag(basePlan())).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is true when the rag feature flag is on', () => {
|
|
||||||
expect(
|
|
||||||
planAllowsRag(basePlan({ features: { text_generation: true, image_generation: false, rag: true, all_future_features: false } }))
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is true when all_future_features unlocks it', () => {
|
|
||||||
expect(
|
|
||||||
planAllowsRag(basePlan({ features: { text_generation: true, image_generation: true, rag: false, all_future_features: true } }))
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a SubscriptionMe wrapper and reads its plan', () => {
|
|
||||||
const subscription: SubscriptionMe = {
|
|
||||||
plan: basePlan({ features: { text_generation: true, image_generation: false, rag: true, all_future_features: false } }),
|
|
||||||
status: 'active',
|
|
||||||
source: 'stripe',
|
|
||||||
needs_checkout: false,
|
|
||||||
stripe_subscription_id: 'sub_1',
|
|
||||||
usage: baseUsage,
|
|
||||||
};
|
|
||||||
expect(planAllowsRag(subscription)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false when a subscription has no plan yet', () => {
|
|
||||||
const subscription: SubscriptionMe = {
|
|
||||||
plan: null,
|
|
||||||
status: 'none',
|
|
||||||
source: 'none',
|
|
||||||
needs_checkout: true,
|
|
||||||
stripe_subscription_id: '',
|
|
||||||
usage: baseUsage,
|
|
||||||
};
|
|
||||||
expect(planAllowsRag(subscription)).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ 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;
|
||||||
@@ -27,7 +25,6 @@ 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;
|
||||||
@@ -37,7 +34,6 @@ export type FinancePayment = {
|
|||||||
export type PlanFeatures = {
|
export type PlanFeatures = {
|
||||||
text_generation: boolean;
|
text_generation: boolean;
|
||||||
image_generation: boolean;
|
image_generation: boolean;
|
||||||
rag: boolean;
|
|
||||||
all_future_features: boolean;
|
all_future_features: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,111 +74,9 @@ 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;
|
|
||||||
current_period_end?: string | null;
|
|
||||||
usage: SubscriptionUsage;
|
usage: SubscriptionUsage;
|
||||||
};
|
};
|
||||||
|
|
||||||
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. */
|
|
||||||
export function isComplimentarySubscription(
|
|
||||||
subscription: Pick<SubscriptionMe, 'source' | 'needs_checkout'> | null | undefined,
|
|
||||||
hasPortalAccess: boolean
|
|
||||||
): boolean {
|
|
||||||
if (!subscription) return false;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Higher-priced selectable plans relative to the current plan. */
|
|
||||||
export function higherSelectablePlans(
|
|
||||||
plans: SubscriptionPlanInfo[],
|
|
||||||
current: SubscriptionPlanInfo | null | undefined
|
|
||||||
): SubscriptionPlanInfo[] {
|
|
||||||
const selectable = plans.filter((p) => p.is_selectable);
|
|
||||||
if (!current) return selectable;
|
|
||||||
return selectable.filter(
|
|
||||||
(p) =>
|
|
||||||
p.slug !== current.slug &&
|
|
||||||
(p.price_cents > current.price_cents || p.sort_order > current.sort_order)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if the plan (or a subscription's plan) includes RAG document search, or unlocks all future features. */
|
|
||||||
export function planAllowsRag(
|
|
||||||
input: SubscriptionPlanInfo | SubscriptionMe | null | undefined
|
|
||||||
): boolean {
|
|
||||||
if (!input) return false;
|
|
||||||
const plan: SubscriptionPlanInfo | null | undefined =
|
|
||||||
'features' in input ? input : input.plan;
|
|
||||||
if (!plan) return false;
|
|
||||||
return Boolean(plan.features?.rag || plan.features?.all_future_features);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function otherSelectablePlans(
|
|
||||||
plans: SubscriptionPlanInfo[],
|
|
||||||
currentSlug: string | null | undefined
|
|
||||||
): SubscriptionPlanInfo[] {
|
|
||||||
return plans.filter((p) => p.is_selectable && p.slug !== currentSlug);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Display provider-reported token counts; never invent 0 for missing usage. */
|
/** Display provider-reported token counts; never invent 0 for missing usage. */
|
||||||
export function formatTokenCount(value: number | null | undefined): string {
|
export function formatTokenCount(value: number | null | undefined): string {
|
||||||
if (value === null || value === undefined) return '—';
|
if (value === null || value === undefined) return '—';
|
||||||
@@ -230,12 +124,9 @@ 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) =>
|
(inv) => Boolean(inv.stripe_subscription_id) || inv.status === 'paid'
|
||||||
(inv.provider || 'stripe').toLowerCase() === 'stripe' &&
|
|
||||||
(Boolean(inv.stripe_subscription_id) || inv.status === 'paid')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { upsertPromptFeedback, clearPromptFeedback } from './promptFeedback';
|
|
||||||
import { axiosInstance } from '../../axiosApi';
|
|
||||||
|
|
||||||
jest.mock('../../axiosApi', () => ({
|
|
||||||
axiosInstance: {
|
|
||||||
post: jest.fn(),
|
|
||||||
delete: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('promptFeedback optimistic helpers', () => {
|
|
||||||
const post = axiosInstance.post as jest.Mock;
|
|
||||||
const del = axiosInstance.delete as jest.Mock;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
post.mockReset();
|
|
||||||
del.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('posts upsert payload', async () => {
|
|
||||||
post.mockResolvedValue({ data: { rating: 'up' } });
|
|
||||||
await upsertPromptFeedback(42, { rating: 'up' });
|
|
||||||
expect(post).toHaveBeenCalledWith('prompt_feedback', {
|
|
||||||
prompt_id: 42,
|
|
||||||
rating: 'up',
|
|
||||||
reason: undefined,
|
|
||||||
comment: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes vote by prompt_id', async () => {
|
|
||||||
del.mockResolvedValue({});
|
|
||||||
await clearPromptFeedback(42);
|
|
||||||
expect(del).toHaveBeenCalledWith('prompt_feedback', {
|
|
||||||
params: { prompt_id: 42 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces request failures for rollback callers', async () => {
|
|
||||||
post.mockRejectedValue(new Error('network'));
|
|
||||||
await expect(upsertPromptFeedback(1, { rating: 'down' })).rejects.toThrow(
|
|
||||||
'network',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { axiosInstance } from '../../axiosApi';
|
|
||||||
|
|
||||||
export type PromptRating = 'up' | 'down';
|
|
||||||
|
|
||||||
export type PromptFeedbackPayload = {
|
|
||||||
rating: PromptRating;
|
|
||||||
reason?: string | null;
|
|
||||||
comment?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PromptFeedbackResponse = PromptFeedbackPayload & {
|
|
||||||
id?: number;
|
|
||||||
prompt_id?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upsert thumbs rating for an assistant prompt (chat_backend#67).
|
|
||||||
*/
|
|
||||||
export async function upsertPromptFeedback(
|
|
||||||
promptId: number,
|
|
||||||
payload: PromptFeedbackPayload,
|
|
||||||
): Promise<PromptFeedbackResponse> {
|
|
||||||
const { data } = await axiosInstance.post<PromptFeedbackResponse>('prompt_feedback', {
|
|
||||||
prompt_id: promptId,
|
|
||||||
rating: payload.rating,
|
|
||||||
reason: payload.reason ?? undefined,
|
|
||||||
comment: payload.comment ?? undefined,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear a vote for an assistant prompt.
|
|
||||||
*/
|
|
||||||
export async function clearPromptFeedback(promptId: number): Promise<void> {
|
|
||||||
await axiosInstance.delete('prompt_feedback', {
|
|
||||||
params: { prompt_id: promptId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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';
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
type ToastListener = (message: string, severity: 'error' | 'success' | 'info') => void;
|
|
||||||
|
|
||||||
const listeners = new Set<ToastListener>();
|
|
||||||
|
|
||||||
export function subscribeToast(listener: ToastListener): () => void {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () => {
|
|
||||||
listeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showToast(
|
|
||||||
message: string,
|
|
||||||
severity: 'error' | 'success' | 'info' = 'info',
|
|
||||||
): void {
|
|
||||||
listeners.forEach((listener) => listener(message, severity));
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import {
|
|
||||||
parseCitationsData,
|
|
||||||
parseStatusData,
|
|
||||||
parseVersionedFrame,
|
|
||||||
} from './wsFrames';
|
|
||||||
|
|
||||||
describe('wsFrames', () => {
|
|
||||||
it('parses a citations frame', () => {
|
|
||||||
const raw = JSON.stringify({
|
|
||||||
v: 1,
|
|
||||||
type: 'citations',
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
index: 1,
|
|
||||||
title: 'Example',
|
|
||||||
url: 'https://example.com',
|
|
||||||
published_at: '2026-07-03',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const frame = parseVersionedFrame(raw);
|
|
||||||
expect(frame).toEqual({
|
|
||||||
v: 1,
|
|
||||||
type: 'citations',
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
index: 1,
|
|
||||||
title: 'Example',
|
|
||||||
url: 'https://example.com',
|
|
||||||
published_at: '2026-07-03',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(parseCitationsData(frame!.data)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores unknown types without throwing', () => {
|
|
||||||
const frame = parseVersionedFrame(
|
|
||||||
JSON.stringify({ v: 1, type: 'status', data: { stage: 'search' } }),
|
|
||||||
);
|
|
||||||
expect(frame?.type).toBe('status');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for sentinels and plain text', () => {
|
|
||||||
expect(parseVersionedFrame('END_OF_THE_STREAM_ENDER_GAME_42')).toBeNull();
|
|
||||||
expect(parseVersionedFrame('hello world')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty citations for bad data', () => {
|
|
||||||
expect(parseCitationsData(null)).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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* Versioned WebSocket frame envelopes shared by citations (#98) and status (#96).
|
|
||||||
* Shape: { v: 1, type: string, data: unknown }
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type VersionedFrame = {
|
|
||||||
v: number;
|
|
||||||
type: string;
|
|
||||||
data: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Citation = {
|
|
||||||
index: number;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
published_at?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseVersionedFrame(raw: string): VersionedFrame | null {
|
|
||||||
const trimmed = raw?.trim?.() ?? '';
|
|
||||||
if (!trimmed.startsWith('{')) return null;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
if (
|
|
||||||
parsed &&
|
|
||||||
typeof parsed === 'object' &&
|
|
||||||
typeof parsed.v === 'number' &&
|
|
||||||
typeof parsed.type === 'string'
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
v: parsed.v,
|
|
||||||
type: parsed.type,
|
|
||||||
data: parsed.data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// not JSON — fall through to sentinel / stream text handling
|
|
||||||
}
|
|
||||||
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[] {
|
|
||||||
if (!Array.isArray(data)) return [];
|
|
||||||
return data
|
|
||||||
.filter(
|
|
||||||
(item): item is Record<string, unknown> =>
|
|
||||||
!!item && typeof item === 'object' && typeof (item as { index?: unknown }).index === 'number',
|
|
||||||
)
|
|
||||||
.map((item) => ({
|
|
||||||
index: item.index as number,
|
|
||||||
title: typeof item.title === 'string' ? item.title : `Source ${item.index}`,
|
|
||||||
url: typeof item.url === 'string' ? item.url : '',
|
|
||||||
published_at:
|
|
||||||
typeof item.published_at === 'string' ? item.published_at : null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -9,7 +9,6 @@ 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 };
|
||||||
|
|||||||
@@ -3,16 +3,3 @@
|
|||||||
// 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: {} })),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|||||||
Reference in New Issue
Block a user