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

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

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

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

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

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

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

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

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

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

## Test plan
- [x] Unit tests: BillingSection, revenueCat, finance helpers, SignIn/Up, AuthCallback, Header2, DeleteAccount (38 OK)
- [ ] Fill `REACT_APP_REVENUECAT_*` keys in `.env.mobile`
- [ ] Align RC offerings/products with plan slugs
- [ ] `npm run build:mobile` + `cap sync`; sandbox purchase on Android/iOS
- [ ] Confirm web Stripe checkout/portal still work
- [ ] After RC webhook, Account → Billing history shows store invoiceReviewed-on: #102
2026-08-04 03:43:10 -07:00
westfarn 3858b97104 Message actions + citation Sources (#97, #98) (#101)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Failing after 2m10s
## Summary
- Closes [#97](#97) — message action row: copy (raw markdown + code-block copy), thumbs up/down with optimistic updates + down-reason popover, and export menu (PDF / DOCX / CSV / XLSX / TXT) for a single message plus conversation-level export in the chat toolbar.
- Closes [#98](#98) — versioned WS `citations` frame parsing, Sources list under assistant bubbles, clickable `[n]` markers, and history hydrate from `Prompt.citations`.
- Backend companion for ratings: [chat_backend#67](ai_ml_operations/chat_backend#67). Thumbs UI posts to `prompt_feedback` once that lands; votes rehydrate from `conversation_details.feedback`.
- Export libs (`pdfmake`, `docx`, `papaparse`, `xlsx`) are dynamically imported so they stay out of the main path until used.

## Test plan
- [ ] Stream a grounded answer → Sources appear after stream; inline `[n]` highlights the matching source; reload keeps Sources.
- [ ] Non-grounded turn → no Sources header.
- [ ] Copy message + code block; confirm checkmark ~2s; failure path shows toast.
- [ ] Thumbs up/down optimistic UI; clear by re-click; down opens reason popover without blocking the vote (needs chat_backend#67).
- [ ] Export one message and whole conversation in all five formats; check searchable PDF text and DOCX structure.
- [ ] Action row: hover reveal on desktop, always visible on last/touch; hidden while streaming.
- [ ] `npm test -- --testPathPattern='wsFrames|clipboard|exportChat|promptFeedback|ConversationDetailCard'`Reviewed-on: #101
2026-08-04 03:32:17 -07:00
westfarn 5be66dbaa7 iOS App (#99)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m4s
closes #21Reviewed-on: #99
2026-08-03 12:14:21 -07:00
westfarn 4501ee8f3d Docs sync progress, Personal/Company list UX, Analytics heatmap (#92–#94) (#95)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m0s
## Summary
- **#92** — Drive sync progress bar (determinate when total known; indeterminate while listing).
- **#93** — Documents list: Personal vs Company title, company helper copy only on company scope, Personal|Company tabs for company members, search/sort/pagination.
- **#94** — Analytics overhaul: Your activity (heatmap + volume charts) vs Company section for managers/members.

Depends on backend PR for #59/#60 + heatmap API.

## Test plan
- [x] Jest: DocumentStoragePage, Analytics, drive utils
- [ ] Manual: Sync Now shows progress N/M
- [ ] Manual: company user tabs Personal/Company; personal-only users see Personal documents only
- [ ] Manual: Analytics shows heatmap under Your activity

Closes #92
Closes #93
Closes #94Reviewed-on: #95
2026-08-02 04:50:20 -07:00
westfarn 6c96c50d99 Drive Sync now pending UX + error alerts (#90) (#91)
Deploy Beta / deploy-beta (push) Successful in 1m59s
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
## Summary
- Closes [#90](#90)
- Depends on backend [chat_backend#57](ai_ml_operations/chat_backend#57) (202 + pending status)
- Sync now: enqueue, poll until status leaves `pending`, show **error/success alert**
- Per-connection `last_sync_error` shown when status is `error`
- OAuth green “Drive connected” banner unchanged (connect-only)
- Refreshes document list on successful sync

## Test plan
- [x] `drive.test` + `DocumentStoragePage.test`
- [ ] Manual with backend #57: Sync fails (Drive API off) → red alert with truncated error
- [ ] Manual: Sync succeeds → success alert + docs refresh
- [ ] Manual: Syncing… / button disabled while pendingReviewed-on: #91
2026-08-02 04:07:22 -07:00
westfarn d5a885f42e Mobile Conversations toggle in header + theme-aware agent bubbles (#87, #88) (#89)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 2m0s
## Summary
- Closes [#87](#87) — move mobile Conversations toggle inline beside the Hesychia logo in the header (no overlay over chat bubbles)
- Closes [#88](#88) — agent message bubbles follow light/dark theme with correct text contrast; user bubbles unchanged

## Changes
- `Header2`: optional `onOpenConversations` renders compact mobile toggle next to brand; label hides on very narrow screens to avoid crowding the nav hamburger
- `AsyncDashboard2`: wire drawer open via header callback; remove floating `MobileSidebarToggle`
- `ConversationDetailCard`: light-mode agent bubble uses light background + theme text color; dark mode keeps dark translucent bubble + light text

## Test plan
- [ ] Mobile: Conversations control appears beside Hesychia logo; opens/closes drawer as before
- [ ] Mobile: chat message text no longer obscured by floating Conversations chip
- [ ] Narrow phone width: header usable with logo + toggle + hamburger (no overflow)
- [ ] Light mode: agent bubbles light with dark text; user bubbles still gradient
- [ ] Dark mode: agent bubbles dark with light text; readability OK on long replies
- [ ] Unit: `Header2` + `ConversationDetailCard` tests passReviewed-on: #89
2026-08-02 03:39:35 -07:00
westfarn bfed056a0e Documents RAG gate + Drive connect UI (#81-#85) (#86)
Unit Tests / test (push) Successful in 11s
Deploy Beta / unit-tests (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 1m58s
## Summary

Companion to [chat_backend#42](ai_ml_operations/chat_backend#42).

- **#81:** Documents nav + page gated on `plan.features.rag` / `all_future_features`; upgrade CTA for Standard
- **#82:** Upload refreshes list; active toggle via PATCH; clearer company docs labeling
- **#83:** Personal Drive connect (JWT → authorize_url JSON → IdP), list/disconnect/sync/resource ids
- **#84:** Company manager knowledge-source section (`link_company_drive`)
- **#85:** Chat upgrade bubble when WS returns RAG `feature_not_allowed`

## Test plan

- [x] `npm test -- --watchAll=false` (157 OK)
- [ ] Standard plan: Documents hidden; upgrade card if navigated directly
- [ ] Founders/Pro: Documents visible; upload + active toggle
- [ ] Connect Drive (needs BE #42); banner on `?drive_connected=1`
- [ ] Company manager sees company section; member does not
- [ ] RAG-denied chat shows upgrade messageReviewed-on: #86
2026-08-01 14:01:13 -07:00
westfarn 4b685df489 Align Google/Microsoft SSO buttons with brand guidelines (#79) (#80)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 1m59s
## Summary
- Closes [#79](#79)
- Update `SsoButtons` with official Google multicolor "G" (on white) and Microsoft four-square logo
- Apply Google dark-theme button colors (`#131314` / `#8E918F` / `#E3E3E3`) and Microsoft dark button styling
- Use approved labels: `Continue with Google` / `Sign up with Google`, `Sign in with Microsoft`

## Test plan
- [ ] Sign In with Google + Microsoft OAuth enabled → both buttons show logos + correct labels
- [ ] Sign Up → Google shows "Sign up with Google"; Microsoft shows "Sign in with Microsoft"
- [ ] Click each button still starts existing OAuth redirect flow
- [ ] Dark card contrast looks correct; Google "G" remains multicolor on white
- [ ] `npm test -- --watchAll=false --testPathPattern=SignIn.test --testPathPattern=SsoButtons.test` passesReviewed-on: #80
2026-08-01 13:04:51 -07:00
westfarn 70dfa85d22 Show Beta Hesychia brand name on beta deploy (#77) (#78)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 1m56s
## Summary
- Closes [#77](#77)
- Sidenav `brandName` becomes **Beta Hesychia** when `REACT_APP_DEPLOY_ENV=beta`; otherwise stays **Hesychia**

## Test plan
- [ ] Local/default build: sidenav shows `Hesychia`
- [ ] Beta build (`.env.beta` / `REACT_APP_DEPLOY_ENV=beta`): sidenav shows `Beta Hesychia`
- [ ] Production build: sidenav still shows `Hesychia`Reviewed-on: #78
2026-08-01 12:34:26 -07:00
westfarn 19698658b1 Add subscription upgrade/change/cancel UX and account self-delete (#75) (#76)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 1m55s
## Summary
- Closes [#75](#75)
- Companion for [chat_backend#34](ai_ml_operations/chat_backend#34) (self-delete API)
- Account Billing: Upgrade / Change plan / Cancel CTAs (Stripe portal–first), plan picker when multiple public selectable plans exist, cancel confirmation with period-end messaging, complimentary users get no fake cancel
- Usage card Upgrade scrolls/focuses Billing; analytics for upgrade/change/cancel intents
- Danger Zone: email-confirmed `DELETE /user/` then logout → `/signin/`

## Test plan
- [ ] `npm test -- --watchAll=false --testPathPattern='BillingSection|DeleteAccountSection'`
- [ ] Paid Stripe user: Upgrade / Change plan / Cancel → portal (or checkout for higher selectable plan)
- [ ] Cancel confirm shows period-end access copy; scheduled cancel notice when `cancel_at_period_end`
- [ ] Backer/admin: complimentary message, no Cancel/Upgrade
- [ ] Delete account: confirm email → soft-delete → signed out at `/signin/`Reviewed-on: #76
2026-08-01 12:24:10 -07:00
143 changed files with 8714 additions and 460 deletions
+6
View File
@@ -4,3 +4,9 @@
# REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/ # REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/
REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/ REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/
REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/ REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/
# RevenueCat public SDK keys (native IAP). Leave empty until store apps are wired.
REACT_APP_REVENUECAT_APPLE_API_KEY=
REACT_APP_REVENUECAT_GOOGLE_API_KEY=
# Optional: pin a specific offering identifier (defaults to current offering).
# REACT_APP_REVENUECAT_OFFERING_ID=
+12
View File
@@ -23,6 +23,18 @@
*.keystore *.keystore
*.jks *.jks
# Capacitor / iOS CocoaPods + build artifacts (keep ios/ source; ignore Pods / DerivedData)
/ios/App/Pods/
/ios/App/build/
/ios/App/output/
/ios/App/App/public/
/ios/DerivedData/
/ios/**/xcuserdata/
*.mobileprovision
*.p12
*.cer
ExportOptions.plist
# misc # misc
.DS_Store .DS_Store
.env.local .env.local
+12 -1
View File
@@ -20,7 +20,18 @@ 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) | | | Billing Portal Opened | `BILLING_PORTAL_OPENED` | BillingSection (Account) | `{ intent? }` |
| 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
+29 -4
View File
@@ -1,9 +1,10 @@
# Android (Capacitor) # Android (Capacitor)
Ship the CRA web build as an Android app via [Capacitor](https://capacitorjs.com/). Ship the CRA web build as an Android app via [Capacitor](https://capacitorjs.com/).
One codebase: `llm-fe/` → web + Android WebView. One codebase: `llm-fe/` → web + Android + iOS WebView.
**Build & Play deploy steps:** [`android/README.md`](android/README.md). **Build & Play deploy steps:** [`android/README.md`](android/README.md).
**iOS sibling:** [`IOS.md`](IOS.md) / [`ios/README.md`](ios/README.md).
Prerequisites for blockers already merged: JWT-only auth (#22), WebSocket lifecycle (#23), HashRouter (#24). Prerequisites for blockers already merged: JWT-only auth (#22), WebSocket lifecycle (#23), HashRouter (#24).
@@ -39,6 +40,20 @@ npm run android:sync
Default `.env.mobile` matches production (`chatbackend.aimloperations.com`). Point it at beta to flip the shell without touching web deploys. Optional gitignored override: `.env.mobile.local`. Default `.env.mobile` matches production (`chatbackend.aimloperations.com`). Point it at beta to flip the shell without touching web deploys. Optional gitignored override: `.env.mobile.local`.
### RevenueCat IAP (#100)
Native billing uses `@revenuecat/purchases-capacitor` (Capacitor 7 → package **11.x**). Web keeps Stripe Checkout / Customer Portal.
Set public SDK keys in `.env.mobile` (empty placeholders OK until store apps ship):
| Variable | Purpose |
|----------|---------|
| `REACT_APP_REVENUECAT_APPLE_API_KEY` | iOS public SDK key |
| `REACT_APP_REVENUECAT_GOOGLE_API_KEY` | Android public SDK key |
| `REACT_APP_REVENUECAT_OFFERING_ID` | Optional offering pin (else current) |
After install / key changes: `npm run build:mobile` (runs `cap sync`). More detail: [`MONETIZATION.md`](MONETIZATION.md).
## Versioning ## Versioning
In `android/app/build.gradle`: In `android/app/build.gradle`:
@@ -63,6 +78,8 @@ Regenerate Android densities after changing source art:
npm run assets:generate npm run assets:generate
``` ```
Same command also regenerates iOS `Assets.xcassets` (see [`IOS.md`](IOS.md)).
## Signing (upload keystore) ## Signing (upload keystore)
**Never commit keystores.** Store outside the repo (e.g. host secrets / password manager). **Never commit keystores.** Store outside the repo (e.g. host secrets / password manager).
@@ -115,12 +132,20 @@ Out of band ops (not automated here):
1. Google Play developer account 1. Google Play developer account
2. Privacy policy URL (product site / legal) 2. Privacy policy URL (product site / legal)
3. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView) 3. **Account deletion URL** (Data safety / Account deletion) → public page:
4. Screenshots for phone (+ tablet if targeting) - Canonical: `https://hesychia.ai/account-deletion/`
5. Upload `.aab` to **internal testing** track before production - Legacy host until DNS cutover: `https://chat.aimloperations.com/account-deletion/`
- Capacitor HashRouter: `/#/account-deletion/`
4. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView)
5. Screenshots for phone (+ tablet if targeting)
6. Upload `.aab` to **internal testing** track before production
In-app delete remains Account → **Delete my account** (`DELETE /api/user/`). The public URL
above is for Play reviewers and users who never open the app (#103).
## Related issues ## Related issues
- [#20](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/20) — this Android wrap - [#20](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/20) — this Android wrap
- [#21](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/21) — iOS (reuses this scaffolding) - [#21](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/21) — iOS (reuses this scaffolding)
- [#22](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/22) / [#23](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/23) / [#24](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/24) — auth / WS / routing - [#22](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/22) / [#23](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/23) / [#24](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/24) — auth / WS / routing
- [#103](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/103) — Play public account-deletion URL
+111
View File
@@ -0,0 +1,111 @@
# iOS (Capacitor)
Ship the CRA web build as an iOS app via [Capacitor](https://capacitorjs.com/)
WKWebView. One codebase: `llm-fe/` → web + Android + iOS.
**Build & Xcode steps:** [`ios/README.md`](ios/README.md).
Prerequisites already merged: JWT-only auth (#22), WebSocket lifecycle (#23),
HashRouter (#24), Android Capacitor scaffolding (#20).
## Prerequisites
- **macOS** with **Xcode** 15+ (CI runners are Linux — iOS builds are **manual**
on a Mac until a hosted macOS runner is added)
- **Node.js 20** (matches CI; Capacitor **7.x**)
- **CocoaPods** (`sudo gem install cocoapods` or Homebrew `pod`)
- Apple Developer Program membership for device / TestFlight / App Store signing
(simulator debug runs without a paid membership using automatic signing)
## One-time / day-to-day
```bash
cd llm-fe
npm ci
npm run build:mobile # .env.mobile → CRA build → cap sync (android + ios)
npm run ios:open # opens ios/App/App.xcworkspace in Xcode
```
Or sync only after an existing `build/`:
```bash
npm run ios:sync
```
**Always open the `.xcworkspace`**, not the `.xcodeproj` (CocoaPods).
## Environment
Same as Android — see [`ANDROID.md`](ANDROID.md) § Environment.
| File | Purpose |
|------|---------|
| `.env.mobile` | Endpoints baked into the **mobile** bundle (`build:mobile`) |
| `.env.mobile.local` | Optional gitignored override |
Default `.env.mobile` points at production (`chatbackend.aimloperations.com`).
WebView origin is `capacitor://localhost`. Backend CORS/CSRF already trusts it
(`chat_backend` `CAPACITOR_WEBVIEW_ORIGINS`). Auth is header JWT — no cookies.
## Versioning
In Xcode target **App** → General, or `ios/App/App.xcodeproj/project.pbxproj`:
| Field | Xcode / build setting | Align with |
|-------|----------------------|------------|
| Version | `MARKETING_VERSION` (`CFBundleShortVersionString`) | `package.json` `version` + Android `versionName` (currently `0.1.0`) |
| Build | `CURRENT_PROJECT_VERSION` (`CFBundleVersion`) | Android `versionCode` (integer; **must increase** every TestFlight / App Store upload) |
## Icons / splash
Source art in `llm-fe/assets/` (same as Android):
- `icon.png` / `splash.png` / `splash-dark.png`
Regenerate iOS + Android densities:
```bash
npm run assets:generate
```
## Signing
**Never commit certificates, profiles, or `.p12` files.**
1. Xcode → Signing & Capabilities → Team (Automatic for debug / internal).
2. Bundle ID: **`ai.hesychia.chat`** (matches Android `applicationId` / Capacitor `appId`).
3. For distribution: App Store Connect app, distribution cert, provisioning profile
— keep credentials outside the repo (password manager / CI secrets later).
## Manual QA checklist
On **simulator and physical iPhone**:
- [ ] Login / logout / password reset
- [ ] Chat streaming + markdown / code blocks (selection + copy)
- [ ] Charts (`recharts`)
- [ ] Dark / light theme
- [ ] Keyboard does not cover compose input; notch / Dynamic Island / home indicator OK
- [ ] No rubber-band overscroll on chat shell
- [ ] Background → resume keeps or reconnects WebSocket (`appStateChange` + visibility)
- [ ] Cellular ↔ WiFi switch reconnects socket
- [ ] Airplane mode → restore reconnects
## TestFlight / App Store (follow-on)
Out of band (not automated here; separate deploy tickets):
1. Apple Developer Program + App ID `ai.hesychia.chat`
2. Privacy policy URL + App Privacy questionnaire
3. In-app account deletion if sign-up is offered (guideline)
4. Enough native shell value that review does not reject as 4.2 “repackaged website”
5. Archive → Upload → TestFlight internal → App Store
Push notifications, offline cache, Sign in with Apple — out of scope for this wrap.
## Related issues
- [#21](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/21) — this iOS wrap
- [#20](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/20) — Android 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
+40
View File
@@ -0,0 +1,40 @@
# Monetization (web Stripe + native RevenueCat)
## Channels
| Runtime | Checkout / manage | Ledger |
|---------|-------------------|--------|
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/monetization/...` | Stripe webhooks |
| Native Capacitor | RevenueCat IAP (`@revenuecat/purchases-capacitor`) | RevenueCat webhooks → same invoice/payment tables |
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`.
## App user id
RevenueCat `appUserID` = Django user pk as string.
1. Prefer `id` from `/user/get/` (serializer returns all model fields).
2. Else JWT `user_id` claim (`SIMPLE_JWT.USER_ID_CLAIM`).
Backend webhook resolver accepts numeric pk (or email fallback).
## Mobile env keys
In `.env.mobile`:
- `REACT_APP_REVENUECAT_APPLE_API_KEY`
- `REACT_APP_REVENUECAT_GOOGLE_API_KEY`
- `REACT_APP_REVENUECAT_OFFERING_ID` (optional)
Package identifiers in the RC dashboard should include plan slugs (e.g. `founders`) so `purchasePlan(planSlug)` can match packages/products. Optional backend `REVENUECAT_PRODUCT_PLAN_MAP` maps product id → plan slug.
## Auth hooks
- `Purchases.configure` once on native (first purchase / logIn).
- `logIn(appUserID)` after SignIn / SignUp / AuthCallback / AccountContext load.
- `logOut` on Header2 sign-out and Delete account.
## Related
- Issue #100 (FE) + companion backend RevenueCat webhook PR
- [`ANDROID.md`](ANDROID.md) / [`IOS.md`](IOS.md) for store builds
+9 -3
View File
@@ -107,9 +107,15 @@ Without `keystore.properties`, release builds are unsigned; debug builds do not
1. [Play Console](https://play.google.com/console) → app **Hesychia** (`ai.hesychia.chat`). 1. [Play Console](https://play.google.com/console) → app **Hesychia** (`ai.hesychia.chat`).
2. Complete store listing: privacy policy URL, data-safety form, screenshots. 2. Complete store listing: privacy policy URL, data-safety form, screenshots.
3. **Testing → Internal testing** → create release → upload `app-release.aab`. 3. Set **Account deletion URL** to the public instructions page (no sign-in required):
4. Add testers, roll out internal track, verify on devices. - `https://hesychia.ai/account-deletion/` (preferred product host)
5. Promote to closed / open / production only after internal QA passes. - or `https://chat.aimloperations.com/account-deletion/` until hesychia.ai cutover
4. **Testing → Internal testing** → create release → upload `app-release.aab`.
5. Add testers, roll out internal track, verify on devices.
6. Promote to closed / open / production only after internal QA passes.
See also [`../ANDROID.md`](../ANDROID.md) Play checklist and issue
[#103](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/103).
Each new upload needs a higher `versionCode`. Each new upload needs a higher `versionCode`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 643 KiB

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 KiB

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 643 KiB

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 KiB

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 B

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+9
View File
@@ -6,11 +6,20 @@ const config: CapacitorConfig = {
webDir: 'build', webDir: 'build',
server: { server: {
// Bundled assets; HashRouter (#24) handles deep links without a rewrite server. // Bundled assets; HashRouter (#24) handles deep links without a rewrite server.
// Android: https://localhost — iOS: capacitor://localhost (CORS trusted in chat_backend).
androidScheme: 'https', androidScheme: 'https',
iosScheme: 'capacitor',
}, },
android: { android: {
allowMixedContent: false, allowMixedContent: false,
}, },
ios: {
// Prefer content-driven insets; CSS env(safe-area-inset-*) handles notch / home indicator.
contentInset: 'automatic',
// Keep WebView scroll on; rubber-band bounce disabled in AppDelegate after load.
scrollEnabled: true,
preferredContentMode: 'mobile',
},
plugins: { plugins: {
SplashScreen: { SplashScreen: {
launchAutoHide: true, launchAutoHide: true,
+20
View File
@@ -0,0 +1,20 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata
# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
# Signing / export artifacts — never commit
*.mobileprovision
*.p12
*.cer
ExportOptions.plist
*.ipa
@@ -0,0 +1,408 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 48;
objects = {
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
isa = PBXGroup;
children = (
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
);
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
);
name = Products;
sourceTree = "<group>";
};
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
path = App;
sourceTree = "<group>";
};
7F8756D8B27F46E3366F6CEA /* Pods */ = {
isa = PBXGroup;
children = (
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
);
name = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = (
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC30C1FED79650016851F /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC3111FED79650016851F /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
504EC3151FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = ai.hesychia.chat;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = ai.hesychia.chat;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3141FED79650016851F /* Debug */,
504EC3151FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,
504EC3181FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:App.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
+64
View File
@@ -0,0 +1,64 @@
import UIKit
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Disable WKWebView rubber-band overscroll so the MUI chat layout feels native.
NotificationCenter.default.addObserver(
self,
selector: #selector(disableWebViewBounce),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
return true
}
@objc private func disableWebViewBounce() {
guard let root = window?.rootViewController else { return }
applyNoBounce(to: root)
}
private func applyNoBounce(to controller: UIViewController) {
if let bridge = controller as? CAPBridgeViewController,
let scrollView = bridge.webView?.scrollView {
scrollView.bounces = false
scrollView.alwaysBounceVertical = false
scrollView.alwaysBounceHorizontal = false
}
for child in controller.children {
applyNoBounce(to: child)
}
if let presented = controller.presentedViewController {
applyNoBounce(to: presented)
}
}
func applicationWillResignActive(_ application: UIApplication) {
}
func applicationDidEnterBackground(_ application: UIApplication) {
}
func applicationWillEnterForeground(_ application: UIApplication) {
}
func applicationDidBecomeActive(_ application: UIApplication) {
disableWebViewBounce()
}
func applicationWillTerminate(_ application: UIApplication) {
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

@@ -0,0 +1,14 @@
{
"images": [
{
"idiom": "universal",
"size": "1024x1024",
"filename": "AppIcon-512@2x.png",
"platform": "ios"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,56 @@
{
"images": [
{
"idiom": "universal",
"filename": "Default@1x~universal~anyany.png",
"scale": "1x"
},
{
"idiom": "universal",
"filename": "Default@2x~universal~anyany.png",
"scale": "2x"
},
{
"idiom": "universal",
"filename": "Default@3x~universal~anyany.png",
"scale": "3x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"idiom": "universal",
"scale": "1x",
"filename": "Default@1x~universal~anyany-dark.png"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"idiom": "universal",
"scale": "2x",
"filename": "Default@2x~universal~anyany-dark.png"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"idiom": "universal",
"scale": "3x",
"filename": "Default@3x~universal~anyany-dark.png"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</imageView>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="Splash" width="1366" height="1366"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
</dependencies>
<scenes>
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Hesychia</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
</plist>
+27
View File
@@ -0,0 +1,27 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
# Product -> Clean Build Folder after new Cordova plugins installed
# Requires CocoaPods 1.6 or newer
install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
end
target 'App' do
capacitor_pods
# Add your Pods here
end
post_install do |installer|
assertDeploymentTarget(installer)
end
+46
View File
@@ -0,0 +1,46 @@
PODS:
- Capacitor (7.6.8):
- CapacitorCordova
- CapacitorApp (7.1.2):
- Capacitor
- CapacitorCordova (7.6.8)
- CapacitorKeyboard (7.0.6):
- Capacitor
- CapacitorPreferences (7.0.4):
- Capacitor
- CapacitorStatusBar (7.0.6):
- Capacitor
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorKeyboard (from `../../node_modules/@capacitor/keyboard`)"
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
EXTERNAL SOURCES:
Capacitor:
:path: "../../node_modules/@capacitor/ios"
CapacitorApp:
:path: "../../node_modules/@capacitor/app"
CapacitorCordova:
:path: "../../node_modules/@capacitor/ios"
CapacitorKeyboard:
:path: "../../node_modules/@capacitor/keyboard"
CapacitorPreferences:
:path: "../../node_modules/@capacitor/preferences"
CapacitorStatusBar:
:path: "../../node_modules/@capacitor/status-bar"
SPEC CHECKSUMS:
Capacitor: ff6bf01336ac353098378828aff465095a89e459
CapacitorApp: f01a913211780e0718dae9750442c3e23f96e106
CapacitorCordova: e61ee8c40101b8cd011d0224261606a290c082cf
CapacitorKeyboard: a2e0869edd229490ce36aed2549c0d6b95e27ee8
CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6
PODFILE CHECKSUM: c2e7da979a3cb960e70090cd1956727cb3ca3413
COCOAPODS: 1.16.2
+89
View File
@@ -0,0 +1,89 @@
# Hesychia — iOS
Native Capacitor project for `ai.hesychia.chat`. Web UI comes from the CRA
`build/` output in the parent `llm-fe/` package (synced with `npx cap sync`).
Broader Capacitor notes (env files, icons, QA, TestFlight): [`../IOS.md`](../IOS.md).
## Prerequisites
- macOS + **Xcode** 15+
- Node.js **20**
- **CocoaPods** (`pod --version`)
- Open **`App.xcworkspace`** (not `.xcodeproj`)
## Build the web bundle and sync
From the **npm root** (`llm-fe/`, one level up):
```bash
cd ..
npm ci
npm run build:mobile # .env.mobile → react-scripts build → cap sync
```
Open in Xcode:
```bash
npm run ios:open
# or: npx cap open ios
```
Re-sync after changing web code (with an existing `build/`):
```bash
npm run ios:sync
```
Backend URLs are baked in at web-build time via `../.env.mobile` (prod by default).
After cloning, if Pods are missing:
```bash
cd App
pod install
```
## Run on a simulator / device
1. Open `App/App.xcworkspace` in Xcode.
2. Pick a simulator or a paired iPhone (Signing & Capabilities → your Team).
3. Product → Run (⌘R).
CLI (optional):
```bash
xcodebuild -workspace App/App.xcworkspace \
-scheme App \
-destination 'platform=iOS Simulator,name=iPhone 16' \
build
```
## Version before a store release
Edit target **App** → General, or `App.xcodeproj` build settings:
| Field | Build setting | Rule |
|-------|---------------|------|
| Version | `MARKETING_VERSION` | Keep in sync with `../package.json` `version` + Android `versionName` |
| Build | `CURRENT_PROJECT_VERSION` | Integer; **must increase** for every TestFlight / App Store upload |
Currently: version **0.1.0**, build **1**.
## Signing
**Do not commit** `.p12`, `.mobileprovision`, or export option files with secrets.
Debug / simulator: Automatic signing with a personal team is enough.
Distribution: App Store Connect + distribution certificate + profile — credentials
live outside this repo. CI/TestFlight automation is a follow-on ticket.
## Useful paths
| Path | Purpose |
|------|---------|
| `App/App/AppDelegate.swift` | Disables WKWebView rubber-band bounce |
| `App/App/Info.plist` | Display name **Hesychia**, version keys |
| `App/App/Assets.xcassets` | App icon + splash (regenerate via `npm run assets:generate`) |
| `App/Podfile` / `Podfile.lock` | CocoaPods (commit lockfile; Pods/ is gitignored) |
+374 -10
View File
@@ -12,6 +12,7 @@
"@capacitor/app": "^7.1.2", "@capacitor/app": "^7.1.2",
"@capacitor/cli": "^7.6.8", "@capacitor/cli": "^7.6.8",
"@capacitor/core": "^7.6.8", "@capacitor/core": "^7.6.8",
"@capacitor/ios": "^7.6.8",
"@capacitor/keyboard": "^7.0.6", "@capacitor/keyboard": "^7.0.6",
"@capacitor/preferences": "^7.0.4", "@capacitor/preferences": "^7.0.4",
"@capacitor/status-bar": "^7.0.6", "@capacitor/status-bar": "^7.0.6",
@@ -20,20 +21,26 @@
"@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",
"brace-expansion": "file:vendor/brace-expansion-compat",
"chroma-js": "^3.1.2", "chroma-js": "^3.1.2",
"formik": "^2.4.6", "formik": "^2.4.6",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"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",
@@ -46,11 +53,12 @@
"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",
"yup": "^1.5.0", "xlsx": "^0.18.5",
"brace-expansion": "file:vendor/brace-expansion-compat" "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",
@@ -2458,6 +2466,15 @@
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
}, },
"node_modules/@capacitor/ios": {
"version": "7.6.8",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-7.6.8.tgz",
"integrity": "sha512-TAM1FdI1Cfl8e/wLBo7x5m61V/J1fMEpyxU2psZzHzsa436k2pwEME6CQoBRYdw6Q6gud0jZSPwOVrNn+MI7Ag==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^7.6.0"
}
},
"node_modules/@capacitor/keyboard": { "node_modules/@capacitor/keyboard": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-7.0.6.tgz", "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-7.0.6.tgz",
@@ -5009,6 +5026,30 @@
"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",
@@ -5198,6 +5239,24 @@
"react": ">=16.14.0" "react": ">=16.14.0"
} }
}, },
"node_modules/@revenuecat/purchases-capacitor": {
"version": "11.3.2",
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-capacitor/-/purchases-capacitor-11.3.2.tgz",
"integrity": "sha512-3T4/lcAwpbPagrT4DuXvJ+8RewzFmHf3fQJQuhVy+uTQk5HGMHNlXcm2A/UdyHtWqU+/VqWbCClPFYGnpnNXAQ==",
"license": "MIT",
"dependencies": {
"@revenuecat/purchases-typescript-internal-esm": "17.25.0"
},
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@revenuecat/purchases-typescript-internal-esm": {
"version": "17.25.0",
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal-esm/-/purchases-typescript-internal-esm-17.25.0.tgz",
"integrity": "sha512-KC4BjFaQclXqFafG1Enh7t8GSwdg8p905by7UrHq0WSJmLhOR6yXSj6WDv1iwsFSAZxPPwgozNxt+U6IOFTdCA==",
"license": "MIT"
},
"node_modules/@rollup/plugin-babel": { "node_modules/@rollup/plugin-babel": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -5546,18 +5605,18 @@
} }
}, },
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.0", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"peer": true, "license": "MIT",
"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": {
@@ -5969,7 +6028,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==",
"peer": true "license": "MIT"
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -6315,11 +6374,39 @@
"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",
@@ -7048,6 +7135,15 @@
"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",
@@ -7972,11 +8068,29 @@
"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",
@@ -8206,6 +8320,19 @@
"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",
@@ -8380,6 +8507,15 @@
"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",
@@ -8410,6 +8546,15 @@
"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",
@@ -8890,6 +9035,18 @@
"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",
@@ -9696,6 +9853,12 @@
"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",
@@ -9761,7 +9924,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==",
"peer": true "license": "MIT"
}, },
"node_modules/dom-converter": { "node_modules/dom-converter": {
"version": "0.2.0", "version": "0.2.0",
@@ -11390,6 +11553,23 @@
} }
} }
}, },
"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",
@@ -11613,6 +11793,15 @@
"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",
@@ -15349,6 +15538,12 @@
"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",
@@ -15627,6 +15822,25 @@
"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",
@@ -15797,7 +16011,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==",
"peer": true, "license": "MIT",
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -16882,6 +17096,18 @@
"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",
@@ -17021,6 +17247,34 @@
"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",
@@ -17160,6 +17414,14 @@
"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",
@@ -20116,6 +20378,12 @@
"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",
@@ -21040,6 +21308,18 @@
"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",
@@ -21899,6 +22179,12 @@
"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",
@@ -22349,6 +22635,16 @@
"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",
@@ -22357,6 +22653,22 @@
"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",
@@ -23053,6 +23365,24 @@
"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",
@@ -23449,6 +23779,27 @@
"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",
@@ -23503,6 +23854,18 @@
"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",
@@ -23622,6 +23985,7 @@
} }
}, },
"vendor/brace-expansion-compat": { "vendor/brace-expansion-compat": {
"name": "brace-expansion",
"version": "5.0.8", "version": "5.0.8",
"license": "MIT" "license": "MIT"
} }
+12 -2
View File
@@ -7,6 +7,7 @@
"@capacitor/app": "^7.1.2", "@capacitor/app": "^7.1.2",
"@capacitor/cli": "^7.6.8", "@capacitor/cli": "^7.6.8",
"@capacitor/core": "^7.6.8", "@capacitor/core": "^7.6.8",
"@capacitor/ios": "^7.6.8",
"@capacitor/keyboard": "^7.0.6", "@capacitor/keyboard": "^7.0.6",
"@capacitor/preferences": "^7.0.4", "@capacitor/preferences": "^7.0.4",
"@capacitor/status-bar": "^7.0.6", "@capacitor/status-bar": "^7.0.6",
@@ -15,11 +16,14 @@
"@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",
@@ -30,6 +34,8 @@
"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",
@@ -42,6 +48,7 @@
"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": {
@@ -49,10 +56,12 @@
"build": "NODE_ENV=production react-scripts build", "build": "NODE_ENV=production react-scripts build",
"build:prod": "NODE_ENV=production react-scripts build && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/", "build:prod": "NODE_ENV=production react-scripts build && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/",
"build:beta": "bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && mkdir -p /var/www/beta.chat.aimloperations/html && cp -r ./build/* /var/www/beta.chat.aimloperations/html/", "build:beta": "bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && mkdir -p /var/www/beta.chat.aimloperations/html && cp -r ./build/* /var/www/beta.chat.aimloperations/html/",
"build:mobile": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync android", "build:mobile": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync",
"android:open": "npx cap open android", "android:open": "npx cap open android",
"android:sync": "npx cap sync android", "android:sync": "npx cap sync android",
"assets:generate": "npx capacitor-assets generate --android", "ios:open": "npx cap open ios",
"ios:sync": "npx cap sync ios",
"assets:generate": "npx capacitor-assets generate --ios --android",
"test": "NODE_ENV=development react-scripts test", "test": "NODE_ENV=development react-scripts test",
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false", "test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false",
"eject": "react-scripts eject" "eject": "react-scripts eject"
@@ -77,6 +86,7 @@
}, },
"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",
+5
View File
@@ -11,6 +11,7 @@ import NotFound from './llm-fe/pages/NotFound/NotFound';
import { AuthContext } from './llm-fe/contexts/AuthContext'; import { AuthContext } from './llm-fe/contexts/AuthContext';
import TermsOfService from './llm-fe/pages/TermsOfService/TermsOfService'; import TermsOfService from './llm-fe/pages/TermsOfService/TermsOfService';
import PublicTermsOfServicePage from './llm-fe/pages/TermsOfService/PublicTermsOfServicePage'; import PublicTermsOfServicePage from './llm-fe/pages/TermsOfService/PublicTermsOfServicePage';
import AccountDeletionPage from './llm-fe/pages/AccountDeletion/AccountDeletionPage';
import SetPassword from './llm-fe/components/SetPassword/SetPassword'; import SetPassword from './llm-fe/components/SetPassword/SetPassword';
import AsyncDashboard2 from './llm-fe/pages/AsyncDashboard2/AsyncDashboard2'; import AsyncDashboard2 from './llm-fe/pages/AsyncDashboard2/AsyncDashboard2';
import FeedbackPage2 from './llm-fe/pages/FeedbackPage2/FeedbackPage2'; import FeedbackPage2 from './llm-fe/pages/FeedbackPage2/FeedbackPage2';
@@ -24,6 +25,7 @@ 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);
@@ -48,6 +50,7 @@ class App extends Component {
<AnalyticsConsentProvider> <AnalyticsConsentProvider>
<Tracker /> <Tracker />
<AnalyticsSession /> <AnalyticsSession />
<ToastHost />
<AnalyticsConsentBanner /> <AnalyticsConsentBanner />
<div className='site'> <div className='site'>
<main> <main>
@@ -69,6 +72,8 @@ class App extends Component {
{/* Public legal pages (also used for post-login ToS acknowledge) */} {/* Public legal pages (also used for post-login ToS acknowledge) */}
<Route path={"/terms_of_service/"} Component={TermsOfService} /> <Route path={"/terms_of_service/"} Component={TermsOfService} />
<Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} /> <Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} />
{/* Play Console account-deletion URL (#103) — must stay public */}
<Route path={"/account-deletion/"} Component={AccountDeletionPage} />
<Route element={<ProtectedRoutes />}> <Route element={<ProtectedRoutes />}>
<Route path={"/"} index={true} Component={AsyncDashboard2} /> <Route path={"/"} index={true} Component={AsyncDashboard2} />
+2 -2
View File
@@ -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'} intent * @param {'login'|'signup'|'link_drive'|'link_company_drive'} 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'} intent * @param {'login'|'signup'|'link_drive'|'link_company_drive'} 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));
@@ -0,0 +1,134 @@
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import ActivityIndicator from './ActivityIndicator';
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
const theme = {
main: '#336699',
focus: '#224466',
darkMode: true,
colors: {
text: '#ffffff',
cardBackground: 'rgba(0,0,0,0.3)',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const renderIndicator = (
props: React.ComponentProps<typeof ActivityIndicator>,
) =>
render(
<ThemeProvider theme={theme as never}>
<ActivityIndicator {...props} />
</ThemeProvider>,
);
describe('ActivityIndicator (#96)', () => {
it('has status role and polite live region regardless of state', () => {
renderIndicator({ stage: null });
const status = screen.getByRole('status');
expect(status).toHaveAttribute('aria-live', 'polite');
});
it('falls back to three dots when stage is null', () => {
renderIndicator({ stage: null });
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
});
it('renders the label verbatim for unknown/future stage values', () => {
renderIndicator({ stage: 'some_future_stage', label: 'Doing something new' });
expect(screen.getByText('Doing something new')).toBeInTheDocument();
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
});
it('shows optional detail text', () => {
renderIndicator({
stage: 'searching',
label: 'Searching the web',
detail: 'query: best pizza in town',
});
expect(screen.getByText('Searching the web')).toBeInTheDocument();
expect(screen.getByText('query: best pizza in town')).toBeInTheDocument();
});
it('renders completed stages above with check marks', () => {
const history: ActivityHistoryEntry[] = [
{ stage: 'searching', label: 'Searched the web', startedAt: 0, finishedAt: 100 },
{ stage: 'reading', label: 'Read 3 sources', startedAt: 100, finishedAt: 400 },
];
renderIndicator({
stage: 'writing',
label: null,
history,
});
expect(screen.getByText('Searched the web')).toBeInTheDocument();
expect(screen.getByText('Read 3 sources')).toBeInTheDocument();
expect(screen.getAllByText('✓')).toHaveLength(2);
});
it('hides everything when interrupted', () => {
const { container } = renderIndicator({
stage: 'searching',
label: 'Searching the web',
interrupted: true,
});
expect(container).toBeEmptyDOMElement();
});
it('shows a generic label when stage is present but label is cleared (writing stage)', () => {
renderIndicator({ stage: 'writing', label: null });
expect(screen.getByText('Working…')).toBeInTheDocument();
});
describe('prefers-reduced-motion', () => {
const originalMatchMedia = window.matchMedia;
afterEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
});
it('disables the spinner animation when the user prefers reduced motion', () => {
const matchMediaMock = jest.fn().mockImplementation((query: string) => ({
matches: query.includes('prefers-reduced-motion'),
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
}));
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: matchMediaMock,
});
renderIndicator({ stage: 'searching', label: 'Searching the web' });
const spinner = screen.getByTestId('activity-spinner');
expect(spinner).toHaveAttribute('data-reduced-motion', 'true');
});
});
it('shows elapsed time after ~10s and "Still working…" after ~30s of silence', () => {
jest.useFakeTimers();
try {
renderIndicator({ stage: 'searching', label: 'Searching the web' });
act(() => {
jest.advanceTimersByTime(11_000);
});
expect(screen.getByText('11s')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(20_000);
});
expect(screen.getByText('Still working…')).toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,346 @@
import React, { useEffect, useReducer, useRef, useState } from 'react';
import styled, { css, keyframes } from 'styled-components';
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
/** Minimum time a single stage stays visible before advancing to a queued update (#96). */
const MIN_DISPLAY_MS = 400;
const ELAPSED_THRESHOLD_MS = 10_000;
const SILENCE_THRESHOLD_MS = 30_000;
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)')?.matches ?? false;
});
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
if (!mql) return undefined;
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}
// Safari < 14 fallback
mql.addListener(handler);
return () => mql.removeListener(handler);
}, []);
return reduced;
}
type QueueItem = { stage: string; label: string | null; detail: string | null };
type IndicatorState = {
current: QueueItem | null;
queue: QueueItem[];
lastIncomingStage: string | null;
};
type IndicatorAction =
| { type: 'INCOMING'; item: QueueItem | null }
| { type: 'ADVANCE' }
| { type: 'RESET' };
const initialIndicatorState: IndicatorState = {
current: null,
queue: [],
lastIncomingStage: null,
};
function indicatorReducer(state: IndicatorState, action: IndicatorAction): IndicatorState {
switch (action.type) {
case 'RESET':
return initialIndicatorState;
case 'INCOMING': {
if (!action.item) return initialIndicatorState;
// Same stage as the last update seen — refresh content in place (label/detail tweak),
// no need to enforce the min-display queue since it isn't a stage transition.
if (action.item.stage === state.lastIncomingStage) {
if (state.queue.length > 0) {
const queue = [...state.queue];
queue[queue.length - 1] = action.item;
return { ...state, queue };
}
return { ...state, current: action.item };
}
if (!state.current) {
return { current: action.item, queue: [], lastIncomingStage: action.item.stage };
}
return {
...state,
queue: [...state.queue, action.item],
lastIncomingStage: action.item.stage,
};
}
case 'ADVANCE': {
if (state.queue.length === 0) return state;
const [next, ...rest] = state.queue;
return { ...state, current: next, queue: rest };
}
default:
return state;
}
}
const spin = keyframes`
to { transform: rotate(360deg); }
`;
const bounce = keyframes`
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
`;
const Root = styled.div`
display: flex;
flex-direction: column;
gap: 0.35rem;
max-width: 100%;
min-width: 0;
width: 100%;
color: ${({ theme }) => theme.colors.text};
font-size: 0.9rem;
@media (max-width: 360px) {
font-size: 0.85rem;
}
`;
const HistoryList = styled.ul`
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.2rem;
opacity: 0.6;
min-width: 0;
`;
const HistoryItem = styled.li`
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
min-width: 0;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
`;
const CheckMark = styled.span`
flex-shrink: 0;
color: ${({ theme }) => (theme.darkMode ? '#8ee6a0' : '#2e7d32')};
font-weight: 700;
`;
const CurrentRow = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
`;
const Spinner = styled.span<{ $reducedMotion: boolean }>`
flex-shrink: 0;
width: 14px;
height: 14px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
opacity: 0.7;
${({ $reducedMotion }) =>
$reducedMotion
? css`
animation: none;
`
: css`
animation: ${spin} 0.8s linear infinite;
`}
`;
const TextColumn = styled.div`
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
`;
const LabelText = styled.span`
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
`;
const DetailText = styled.span`
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
opacity: 0.65;
margin-top: 0.1rem;
`;
const MetaText = styled.span`
flex-shrink: 0;
font-size: 0.75rem;
opacity: 0.55;
white-space: nowrap;
`;
const DotsRow = styled.div`
display: flex;
align-items: center;
padding: 0.15rem 0;
`;
const Dot = styled.span<{ $delay: string; $reducedMotion: boolean }>`
width: 8px;
height: 8px;
margin: 0 4px;
border-radius: 50%;
background: currentColor;
${({ $reducedMotion, $delay }) =>
$reducedMotion
? css`
opacity: 0.6;
`
: css`
animation: ${bounce} 1.4s infinite ease-in-out both;
animation-delay: ${$delay};
`}
`;
export type ActivityIndicatorProps = {
/** Current stage key from the latest "status" frame, or null before the first update. */
stage: string | null;
label?: string | null;
detail?: string | null;
/** Previously completed stages for this turn, oldest first. */
history?: ActivityHistoryEntry[];
/** Hides the indicator entirely once a stream is interrupted (#96). */
interrupted?: boolean;
};
const ActivityIndicator = ({
stage,
label = null,
detail = null,
history = [],
interrupted = false,
}: ActivityIndicatorProps): JSX.Element | null => {
const [state, dispatch] = useReducer(indicatorReducer, initialIndicatorState);
const reducedMotion = usePrefersReducedMotion();
const overallStartRef = useRef<number | null>(null);
const lastUpdateRef = useRef<number>(Date.now());
const currentSinceRef = useRef<number>(Date.now());
const advanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [, forceTick] = useState(0);
useEffect(() => {
if (interrupted || stage == null) {
overallStartRef.current = null;
dispatch({ type: 'RESET' });
return;
}
if (overallStartRef.current == null) {
overallStartRef.current = Date.now();
}
lastUpdateRef.current = Date.now();
dispatch({ type: 'INCOMING', item: { stage, label, detail } });
}, [stage, label, detail, interrupted]);
// Reset the per-stage clock whenever the displayed stage changes.
useEffect(() => {
currentSinceRef.current = Date.now();
}, [state.current?.stage]);
// Enforce the minimum display time per stage before advancing the queue.
useEffect(() => {
if (advanceTimerRef.current) {
clearTimeout(advanceTimerRef.current);
advanceTimerRef.current = null;
}
if (state.queue.length === 0) return undefined;
const elapsed = Date.now() - currentSinceRef.current;
const remaining = Math.max(MIN_DISPLAY_MS - elapsed, 0);
advanceTimerRef.current = setTimeout(() => {
dispatch({ type: 'ADVANCE' });
}, remaining);
return () => {
if (advanceTimerRef.current) clearTimeout(advanceTimerRef.current);
};
}, [state.queue.length]);
// Tick once a second while active so elapsed/"still working" copy stays fresh.
useEffect(() => {
if (!state.current) return undefined;
const id = setInterval(() => forceTick((n) => n + 1), 1000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [Boolean(state.current)]);
if (interrupted) return null;
const now = Date.now();
const elapsedMs = overallStartRef.current != null ? now - overallStartRef.current : 0;
const silentMs = now - lastUpdateRef.current;
const showElapsed = state.current != null && elapsedMs >= ELAPSED_THRESHOLD_MS;
const stillWorking = state.current != null && silentMs >= SILENCE_THRESHOLD_MS;
const elapsedLabel = showElapsed ? `${Math.floor(elapsedMs / 1000)}s` : null;
return (
<Root role="status" aria-live="polite">
{history.length > 0 && (
<HistoryList>
{history.map((entry, i) => (
<HistoryItem key={`${entry.stage}-${entry.startedAt}-${i}`}>
<CheckMark aria-hidden="true"></CheckMark>
<span>{entry.label}</span>
</HistoryItem>
))}
</HistoryList>
)}
{state.current ? (
<CurrentRow>
<Spinner
aria-hidden="true"
$reducedMotion={reducedMotion}
data-testid="activity-spinner"
data-reduced-motion={reducedMotion}
/>
<TextColumn>
<LabelText>
{stillWorking ? 'Still working…' : state.current.label || 'Working…'}
</LabelText>
{state.current.detail && (
<DetailText title={state.current.detail}>{state.current.detail}</DetailText>
)}
</TextColumn>
{elapsedLabel && !stillWorking && <MetaText>{elapsedLabel}</MetaText>}
</CurrentRow>
) : (
<DotsRow data-testid="activity-dots-fallback">
<Dot $delay="-0.32s" $reducedMotion={reducedMotion} />
<Dot $delay="-0.16s" $reducedMotion={reducedMotion} />
<Dot $delay="0s" $reducedMotion={reducedMotion} />
</DotsRow>
)}
</Root>
);
};
export default ActivityIndicator;
@@ -8,6 +8,9 @@ const mockGet = jest.fn();
const mockPost = jest.fn(); const mockPost = jest.fn();
const assignMock = jest.fn(); const assignMock = jest.fn();
const mockTrackEvent = jest.fn(); const mockTrackEvent = jest.fn();
const mockPurchasePlan = jest.fn();
const mockRestorePurchases = jest.fn();
const mockIsNativePlatform = jest.fn(() => false);
jest.mock('../../../axiosApi', () => ({ jest.mock('../../../axiosApi', () => ({
axiosInstance: { axiosInstance: {
@@ -20,10 +23,24 @@ 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,
@@ -52,8 +69,7 @@ const paidInvoice = {
last_modified: '2026-07-01T12:00:00Z', last_modified: '2026-07-01T12:00:00Z',
}; };
const foundersSubscription = { const foundersPlan = {
plan: {
slug: 'founders', slug: 'founders',
name: 'Founders', name: 'Founders',
description: '', description: '',
@@ -71,11 +87,16 @@ const foundersSubscription = {
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,
@@ -92,12 +113,20 @@ 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,
@@ -120,21 +149,26 @@ 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 === '/finance/invoices/') { if (url === '/monetization/invoices/') {
return Promise.resolve({ data: invoices }); return Promise.resolve({ data: invoices });
} }
if (url === '/finance/payments/') { if (url === '/monetization/payments/') {
return Promise.resolve({ data: payments }); return Promise.resolve({ data: payments });
} }
if (url === '/finance/subscription/') { if (url === '/monetization/subscription/') {
return Promise.resolve({ data: subscription }); return Promise.resolve({ data: subscription });
} }
if (url === '/monetization/plans/') {
return Promise.resolve({ data: plans });
}
return Promise.reject(new Error(`unexpected GET ${url}`)); return Promise.reject(new Error(`unexpected GET ${url}`));
}); });
}; };
@@ -154,6 +188,9 @@ describe('BillingSection', () => {
mockPost.mockReset(); mockPost.mockReset();
assignMock.mockReset(); assignMock.mockReset();
mockTrackEvent.mockReset(); mockTrackEvent.mockReset();
mockPurchasePlan.mockReset();
mockRestorePurchases.mockReset();
mockIsNativePlatform.mockReturnValue(false);
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
configurable: true, configurable: true,
value: { value: {
@@ -171,7 +208,7 @@ describe('BillingSection', () => {
}); });
}); });
it('renders plan summary and invoice history from finance APIs', async () => { it('renders plan summary and paid subscription management CTAs', async () => {
mockFinanceGets({ mockFinanceGets({
invoices: [paidInvoice], invoices: [paidInvoice],
subscription: foundersSubscription, subscription: foundersSubscription,
@@ -179,7 +216,10 @@ describe('BillingSection', () => {
renderBilling(); renderBilling();
expect(await screen.findByRole('button', { name: /Manage subscription/i })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: /^Upgrade$/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();
@@ -189,10 +229,11 @@ describe('BillingSection', () => {
); );
}); });
it('redirects to Stripe Customer Portal on manage billing', async () => { it('opens Stripe portal for change plan when no alternate public plans', 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' },
@@ -201,20 +242,67 @@ describe('BillingSection', () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderBilling(); renderBilling();
await screen.findByRole('button', { name: /Manage subscription/i }); await screen.findByRole('button', { name: /Change plan/i });
await user.click(screen.getByRole('button', { name: /Manage subscription/i })); await user.click(screen.getByRole('button', { name: /Change plan/i }));
await waitFor(() => { await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/finance/portal/', { expect(mockPost).toHaveBeenCalledWith('/monetization/portal/', {
return_url: 'http://localhost/account/', return_url: 'http://localhost/account/',
}); });
}); });
expect(mockTrackEvent).toHaveBeenCalledWith('Billing Portal Opened'); expect(mockTrackEvent).toHaveBeenCalledWith('Plan Change Started', {
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({
@@ -233,7 +321,7 @@ describe('BillingSection', () => {
await waitFor(() => { await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith( expect(mockPost).toHaveBeenCalledWith(
'/finance/checkout/', '/monetization/checkout/',
expect.objectContaining({ expect.objectContaining({
success_url: expect.stringContaining('/billing/success'), success_url: expect.stringContaining('/billing/success'),
cancel_url: expect.stringContaining('/billing/cancel'), cancel_url: expect.stringContaining('/billing/cancel'),
@@ -274,4 +362,49 @@ describe('BillingSection', () => {
expect(await screen.findByText('No Stripe customer found')).toBeInTheDocument(); expect(await screen.findByText('No Stripe customer found')).toBeInTheDocument();
expect(assignMock).not.toHaveBeenCalled(); expect(assignMock).not.toHaveBeenCalled();
}); });
it('on native uses RevenueCat purchase and shows Restore', async () => {
mockIsNativePlatform.mockReturnValue(true);
mockFinanceGets();
mockPurchasePlan.mockResolvedValue(undefined);
const user = userEvent.setup();
renderBilling();
expect(await screen.findByTestId('restore-purchases')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Complete payment/i }));
await waitFor(() => {
expect(mockPurchasePlan).toHaveBeenCalled();
});
expect(mockPost).not.toHaveBeenCalledWith(
'/monetization/checkout/',
expect.anything()
);
});
it('shows provider badge for RevenueCat invoices', async () => {
mockFinanceGets({
invoices: [
{
...paidInvoice,
provider: 'revenuecat',
stripe_subscription_id: null,
revenuecat_store: 'PLAY_STORE',
hosted_invoice_url: '',
description: 'Store IAP (PLAY_STORE) — founders — INITIAL_PURCHASE',
},
],
subscription: {
...foundersSubscription,
source: 'revenuecat',
stripe_subscription_id: '',
},
});
renderBilling();
expect(await screen.findByText('Play Store')).toBeInTheDocument();
expect(screen.getByText(/Store IAP/)).toBeInTheDocument();
});
}); });
@@ -10,10 +10,23 @@ 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};
@@ -69,6 +82,14 @@ 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;
@@ -116,6 +137,81 @@ 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;
@@ -146,6 +242,14 @@ const InvoiceLink = styled.a`
} }
`; `;
const ProviderBadge = styled.span`
display: inline-block;
font-size: 0.8rem;
font-weight: 600;
opacity: 0.85;
white-space: nowrap;
`;
function apiErrorMessage(error: unknown, fallback: string): string { function apiErrorMessage(error: unknown, fallback: string): string {
const axiosError = error as { const axiosError = error as {
response?: { data?: { detail?: string } }; response?: { data?: { detail?: string } };
@@ -154,32 +258,54 @@ 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 [checkoutLoading, setCheckoutLoading] = useState(false); const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState<string | null>(null);
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] = await Promise.all([ const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] =
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'), await Promise.all([
axiosInstance.get<FinancePayment[]>('/finance/payments/'), axiosInstance.get<FinanceInvoice[]>('/monetization/invoices/'),
axiosInstance.get<SubscriptionMe>('/finance/subscription/'), axiosInstance.get<FinancePayment[]>('/monetization/payments/'),
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);
@@ -192,6 +318,34 @@ 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) {
@@ -199,6 +353,7 @@ const BillingSection = (): JSX.Element => {
key: `invoice-${invoice.id}`, key: `invoice-${invoice.id}`,
date: invoice.created, date: invoice.created,
description: invoice.description || 'Invoice', description: invoice.description || 'Invoice',
provider: invoiceProviderLabel(invoice),
amount: formatMoneyCents( amount: formatMoneyCents(
invoice.amount_paid || invoice.amount_due, invoice.amount_paid || invoice.amount_due,
invoice.currency invoice.currency
@@ -211,19 +366,26 @@ 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 handleManageBilling = async () => { const openPortal = async (intent: PortalIntent) => {
setActionError(''); setActionError('');
setActionNotice('');
setPortalLoading(true); setPortalLoading(true);
try { try {
const returnUrl = `${window.location.origin}/account/`; const returnUrl = `${window.location.origin}/account/`;
const response = await axiosInstance.post<{ portal_url: string }>( const response = await axiosInstance.post<{ portal_url: string }>(
'/finance/portal/', '/monetization/portal/',
{ return_url: returnUrl } { return_url: returnUrl }
); );
const portalUrl = response.data?.portal_url; const portalUrl = response.data?.portal_url;
@@ -231,7 +393,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); trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED, { intent });
window.location.assign(portalUrl); window.location.assign(portalUrl);
} catch (error: unknown) { } catch (error: unknown) {
setActionError( setActionError(
@@ -242,15 +404,69 @@ const BillingSection = (): JSX.Element => {
} }
}; };
const handleStartCheckout = async () => { const handleNativePurchase = async (planSlug?: string, source = 'account_billing') => {
setActionError(''); setActionError('');
setCheckoutLoading(true); setActionNotice('');
setCheckoutLoadingSlug(planSlug || '__default__');
try {
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, {
source,
...(planSlug ? { plan_slug: planSlug } : {}),
});
await purchasePlan(planSlug);
setActionNotice(
'Purchase submitted. Entitlements update after the store confirms — tap Refresh shortly.'
);
await loadBilling();
} catch (error: unknown) {
if (isPurchaseCancelledError(error)) {
setActionNotice('Purchase cancelled.');
return;
}
setActionError(
apiErrorMessage(error, 'Store purchase failed. Try again or Restore purchases.')
);
} finally {
setCheckoutLoadingSlug(null);
}
};
const handleRestore = async () => {
setActionError('');
setActionNotice('');
setRestoreLoading(true);
try {
await restorePurchases();
setActionNotice('Purchases restored. Refreshing billing…');
await loadBilling();
} catch (error: unknown) {
setActionError(apiErrorMessage(error, 'Could not restore purchases. Try again.'));
} finally {
setRestoreLoading(false);
}
};
const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => {
if (native) {
await handleNativePurchase(planSlug, source);
return;
}
setActionError('');
setActionNotice('');
setCheckoutLoadingSlug(planSlug || '__default__');
try { try {
const { success_url, cancel_url } = checkoutReturnUrls(); const { success_url, cancel_url } = checkoutReturnUrls();
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'account_billing' }); trackEvent(AnalyticsEvents.CHECKOUT_STARTED, {
source,
...(planSlug ? { plan_slug: planSlug } : {}),
});
const response = await axiosInstance.post<{ checkout_url: string }>( const response = await axiosInstance.post<{ checkout_url: string }>(
'/finance/checkout/', '/monetization/checkout/',
{ success_url, 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) {
@@ -261,10 +477,81 @@ 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 {
setCheckoutLoading(false); setCheckoutLoadingSlug(null);
} }
}; };
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">
@@ -331,13 +618,19 @@ const BillingSection = (): JSX.Element => {
</SettingRow> </SettingRow>
</> </>
) : null} ) : null}
{primaryInvoice ? (
<SettingRow> <SettingRow>
<SettingLabel>Period end</SettingLabel> <SettingLabel>Period end</SettingLabel>
<SettingValue> <SettingValue>{periodEndLabel}</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}
</> </>
) : ( ) : (
@@ -349,32 +642,172 @@ const BillingSection = (): JSX.Element => {
{!loading && !listError && ( {!loading && !listError && (
<ButtonRow> <ButtonRow>
{hasPortalAccess ? ( {showStripeManage ? (
<>
<StyledButton <StyledButton
type="button" type="button"
onClick={handleManageBilling} onClick={handleUpgradeClick}
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'}
</StyledButton> </SecondaryButton>
) : subscription?.needs_checkout === false ? ( </>
<BodyText style={{ margin: 0 }}> ) : complimentary ? (
Complimentary access no payment required. <BodyText style={{ margin: 0 }} data-testid="complimentary-billing-message">
Complimentary access no payment required. Plan changes and
cancellation are not available for this account.
</BodyText> </BodyText>
) : ( ) : showStoreManage ? (
<>
<StyledButton <StyledButton
type="button" type="button"
onClick={handleStartCheckout} onClick={handleUpgradeClick}
disabled={checkoutLoading} disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
> >
{checkoutLoading ? 'Starting…' : 'Complete payment'} Upgrade
</StyledButton> </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
type="button"
onClick={() => handleStartCheckout()}
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
>
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
</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>
@@ -392,6 +825,7 @@ const BillingSection = (): JSX.Element => {
<thead> <thead>
<tr> <tr>
<Th>Date</Th> <Th>Date</Th>
<Th>Provider</Th>
<Th>Description</Th> <Th>Description</Th>
<Th>Amount</Th> <Th>Amount</Th>
<Th>Status</Th> <Th>Status</Th>
@@ -402,6 +836,9 @@ const BillingSection = (): JSX.Element => {
{historyRows.map((row) => ( {historyRows.map((row) => (
<tr key={row.key}> <tr key={row.key}>
<Td>{formatBillingDate(row.date)}</Td> <Td>{formatBillingDate(row.date)}</Td>
<Td>
<ProviderBadge>{row.provider}</ProviderBadge>
</Td>
<Td>{row.description}</Td> <Td>{row.description}</Td>
<Td>{row.amount}</Td> <Td>{row.amount}</Td>
<Td>{row.status}</Td> <Td>{row.status}</Td>
@@ -421,6 +858,48 @@ 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}
</> </>
); );
}; };
@@ -0,0 +1,74 @@
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();
});
});
@@ -1,9 +1,10 @@
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 theme = { const darkTheme = {
main: '#336699', main: '#336699',
focus: '#224466', focus: '#224466',
darkMode: true, darkMode: true,
@@ -14,14 +15,30 @@ const theme = {
}, },
}; };
const renderCard = (props: { const lightTheme = {
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', () => {
@@ -43,4 +60,90 @@ 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,6 +1,13 @@
import React from "react"; import React, { useCallback, useMemo } from 'react';
import Markdown from "markdown-to-jsx"; import Markdown from 'markdown-to-jsx';
import styled, { keyframes } from "styled-components"; import { Link } from 'react-router-dom';
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); }
@@ -10,10 +17,16 @@ 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 }>`
@@ -26,19 +39,33 @@ 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.7)"}; : 'rgba(0, 0, 0, 0.06)'};
color: #fff; color: ${(props) =>
props.$isUser || props.theme.darkMode ? '#fff' : props.theme.colors.text};
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid ${(props) => props.theme.darkMode ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"}; border: 1px solid ${(props) =>
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); props.$isUser
? 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: rgba(0, 0, 0, 0.3); background: ${(props) =>
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;
@@ -51,7 +78,8 @@ const Bubble = styled.div<{ $isUser: boolean }>`
} }
& a { & a {
color: #a0c4ff; color: ${(props) =>
props.$isUser || props.theme.darkMode ? '#a0c4ff' : props.theme.main};
text-decoration: underline; text-decoration: underline;
} }
@@ -67,33 +95,81 @@ const Bubble = styled.div<{ $isUser: boolean }>`
} }
`; `;
const LoadingDot = styled.div` const UpgradeNotice = styled.div`
width: 8px; display: flex;
height: 8px; flex-direction: column;
background: #fff; align-items: flex-start;
border-radius: 50%; gap: 0.65rem;
margin: 0 4px; `;
animation: bounce 1.4s infinite ease-in-out both;
&:nth-child(1) { animation-delay: -0.32s; } const UpgradeLink = styled(Link)`
&:nth-child(2) { animation-delay: -0.16s; } 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;
@keyframes bounce { &:hover {
0%, 80%, 100% { transform: scale(0); } opacity: 0.9;
40% { transform: scale(1); }
} }
`; `;
const LoadingContainer = styled.div` const CitationButton = styled.button`
display: flex; display: inline;
align-items: center; margin: 0 0.1rem;
justify-content: center; padding: 0 0.25rem;
padding: 0.5rem; 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 }) => {
@@ -101,7 +177,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"
/> />
); );
@@ -109,7 +185,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>
); );
@@ -118,51 +194,131 @@ 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}>
<LoadingContainer> <ActivityIndicator
<LoadingDot /> stage={activityStage}
<LoadingDot /> label={activityLabel}
<LoadingDot /> detail={activityDetail}
</LoadingContainer> history={activityHistory}
interrupted={activityInterrupted}
/>
</Bubble> </Bubble>
</MessageContainer> </MessageContainer>
); );
} }
let contentToAdd = message; const errorPayload = parseChatErrorPayload(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':
contentToAdd = parsedMessage.content; rawForCopy = 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: {
@@ -171,12 +327,31 @@ 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,25 +1,72 @@
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter"; import React, { useState } from 'react';
import tsx from "react-syntax-highlighter/dist/cjs/languages/prism/tsx"; import styled from 'styled-components';
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
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);
type CustomCodeBlock = { const Wrapper = styled.div`
children: string, position: relative;
className: string max-width: 100%;
`;
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}>
{children} {code}
</SyntaxHighlighter> </SyntaxHighlighter>
) </Wrapper>
} );
};
export default CustomCodeBlock; export default CustomCodeBlock;
@@ -1,14 +1,22 @@
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 => {
if ("type" in children && children["type"] === "code") { const child = Array.isArray(children) ? children[0] : children;
return CustomCodeBlock({children: children["props"]["children"], className: children["props"]["className"] }); if (child && typeof child === 'object' && 'type' in child && child.type === 'code') {
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,6 +39,8 @@ 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(() => {
@@ -115,7 +117,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="Hesychia" brandName={brandName}
routes={[]} // {routes} routes={[]} // {routes}
onMouseEnter={handleOnMouseEnter} onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave} onMouseLeave={handleOnMouseLeave}
@@ -136,7 +138,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="Hesychia" brandName={brandName}
routes={[]} // {routes} routes={[]} // {routes}
onMouseEnter={handleOnMouseEnter} onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave} onMouseLeave={handleOnMouseLeave}
@@ -0,0 +1,126 @@
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/');
});
});
@@ -0,0 +1,252 @@
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;
@@ -0,0 +1,534 @@
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;
@@ -0,0 +1,127 @@
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,6 +5,8 @@ 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`
@@ -30,6 +32,50 @@ 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;
@@ -50,6 +96,8 @@ 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) {
@@ -138,6 +186,14 @@ 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" />
@@ -149,14 +205,22 @@ 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 = ({ absolute = false, light = false, isMini = false }: Header2Props): JSX.Element => { const Header2 = ({
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 {
@@ -167,6 +231,7 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro
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);
@@ -180,16 +245,28 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro
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>
<NavLink onClick={() => navigate('/document_storage/')}>Documents</NavLink> {hasRag && <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>
@@ -204,7 +281,7 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro
<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>
<NavLink onClick={() => handleNavClick('/document_storage/')}>Documents</NavLink> {hasRag && <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>
@@ -0,0 +1,361 @@
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;
@@ -0,0 +1,230 @@
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;
@@ -0,0 +1,46 @@
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,21 +20,32 @@ const Divider = styled.div`
} }
`; `;
const SsoButton = styled.button` const ButtonStack = styled.div`
display: flex;
flex-direction: column;
width: 100%; width: 100%;
background: rgba(255, 255, 255, 0.06); gap: 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 0.5rem;
color: #fff;
padding: 0.85rem 1rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
margin-top: 0.6rem; margin-top: 0.6rem;
`;
&:hover { /** Shared layout for IdP buttons — equal size / visual weight. */
background: rgba(255, 255, 255, 0.12); const SsoButtonBase = styled.button`
display: flex;
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;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
&:hover:not(:disabled) {
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -45,6 +56,93 @@ const SsoButton = 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;
@@ -56,6 +154,9 @@ 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);
@@ -66,24 +167,34 @@ const SsoButtons = ({ intent, providers, disabled = false }: SsoButtonsProps): J
return ( return (
<> <>
<Divider>or continue with</Divider> <Divider>or continue with</Divider>
<ButtonStack>
{google && ( {google && (
<SsoButton <GoogleButton
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => startOAuth('google', intent)} onClick={() => startOAuth('google', intent)}
> >
Continue with Google <GoogleLogoBadge>
</SsoButton> <LogoMark>
<GoogleGIcon />
</LogoMark>
</GoogleLogoBadge>
{googleLabel(intent)}
</GoogleButton>
)} )}
{microsoft && ( {microsoft && (
<SsoButton <MicrosoftButton
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => startOAuth('microsoft', intent)} onClick={() => startOAuth('microsoft', intent)}
> >
Continue with Microsoft <LogoMark>
</SsoButton> <MicrosoftLogoIcon />
</LogoMark>
Sign in with Microsoft
</MicrosoftButton>
)} )}
</ButtonStack>
</> </>
); );
}; };
@@ -1,5 +1,7 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlined from '@mui/icons-material/InfoOutlined';
import { import {
useMaterialUIController, useMaterialUIController,
setDarkMode, setDarkMode,
@@ -48,6 +50,16 @@ const SettingLabel = styled.span`
font-size: 1.1rem; font-size: 1.1rem;
color: ${({ theme }) => theme.colors.text}; color: ${({ theme }) => theme.colors.text};
font-weight: 500; font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.4rem;
`;
const InfoIcon = styled(InfoOutlined)`
font-size: 1rem !important;
opacity: 0.7;
cursor: help;
vertical-align: middle;
`; `;
const ToggleSwitch = styled.label` const ToggleSwitch = styled.label`
@@ -93,6 +105,11 @@ const Checkbox = styled.input`
&:checked + ${Slider}:before { &:checked + ${Slider}:before {
transform: translateX(26px); transform: translateX(26px);
} }
&:disabled + ${Slider} {
opacity: 0.5;
cursor: not-allowed;
}
`; `;
const ColorPickerContainer = styled.div` const ColorPickerContainer = styled.div`
@@ -115,6 +132,23 @@ const ColorButton = styled.button<{ color: string; isSelected: boolean }>`
} }
`; `;
const ErrorText = styled.p`
color: #ff6b6b;
margin: 0.75rem 0 0 0;
font-size: 0.95rem;
`;
const CONTEXT_TOOLTIP =
'This will use previous conversations to better customize the experience.';
function prefsErrorMessage(error: unknown, fallback: string): string {
const axiosError = error as {
response?: { data?: { detail?: string } };
message?: string;
};
return axiosError.response?.data?.detail || axiosError.message || fallback;
}
type PreferencesValues = { type PreferencesValues = {
order: boolean; order: boolean;
} }
@@ -128,6 +162,10 @@ const ThemeSettingsCard = (): JSX.Element => {
const themeColors = Object.keys(palettes); const themeColors = Object.keys(palettes);
const [order, setOrder] = useState<boolean>(true); const [order, setOrder] = useState<boolean>(true);
const [useConversationContext, setUseConversationContext] = useState<boolean>(false);
const [prefsLoading, setPrefsLoading] = useState<boolean>(true);
const [contextSaving, setContextSaving] = useState<boolean>(false);
const [prefsError, setPrefsError] = useState<string>('');
const { updatePreferences } = useContext(PreferenceContext); const { updatePreferences } = useContext(PreferenceContext);
const handleConversationOrder = async ({ order }: PreferencesValues): Promise<void> => { const handleConversationOrder = async ({ order }: PreferencesValues): Promise<void> => {
@@ -141,17 +179,50 @@ const ThemeSettingsCard = (): JSX.Element => {
} }
} }
async function getConversationOrder() { async function getConversationPreferences() {
setPrefsLoading(true);
setPrefsError('');
try { try {
const { data, }: AxiosResponse<PreferencesType> = await axiosInstance.get(`/conversation_preferences`); const { data }: AxiosResponse<PreferencesType> = await axiosInstance.get(
`/conversation_preferences`
);
setOrder(data.order); setOrder(data.order);
setUseConversationContext(Boolean(data.use_conversation_context));
} catch (error) { } catch (error) {
console.log(error) setPrefsError(
prefsErrorMessage(error, 'Could not load account preferences.')
);
} finally {
setPrefsLoading(false);
} }
} }
const handleConversationContext = async (enabled: boolean): Promise<void> => {
const previous = useConversationContext;
setUseConversationContext(enabled);
setContextSaving(true);
setPrefsError('');
try {
const { data }: AxiosResponse<PreferencesType> = await axiosInstance.post(
'/conversation_preferences',
{ use_conversation_context: enabled }
);
setUseConversationContext(Boolean(data.use_conversation_context));
} catch (error) {
setUseConversationContext(previous);
setPrefsError(
prefsErrorMessage(
error,
'Could not update conversation context setting. Try again.'
)
);
} finally {
setContextSaving(false);
}
};
useEffect(() => { useEffect(() => {
getConversationOrder(); getConversationPreferences();
}, []) }, [])
const handleDarkMode = () => setDarkMode(dispatch, !darkMode); const handleDarkMode = () => setDarkMode(dispatch, !darkMode);
@@ -193,6 +264,7 @@ const ThemeSettingsCard = (): JSX.Element => {
<Checkbox <Checkbox
type="checkbox" type="checkbox"
checked={order} checked={order}
disabled={prefsLoading}
onChange={() => { onChange={() => {
setOrder(!order); setOrder(!order);
handleConversationOrder({ order: !order }) handleConversationOrder({ order: !order })
@@ -202,6 +274,30 @@ const ThemeSettingsCard = (): JSX.Element => {
</ToggleSwitch> </ToggleSwitch>
</SettingRow> </SettingRow>
<SettingRow>
<SettingLabel>
Use previous conversations
<Tooltip title={CONTEXT_TOOLTIP}>
<span>
<InfoIcon aria-label={CONTEXT_TOOLTIP} />
</span>
</Tooltip>
</SettingLabel>
<ToggleSwitch>
<Checkbox
type="checkbox"
checked={useConversationContext}
disabled={prefsLoading || contextSaving}
onChange={() => {
handleConversationContext(!useConversationContext);
}}
/>
<Slider />
</ToggleSwitch>
</SettingRow>
{prefsError ? <ErrorText role="alert">{prefsError}</ErrorText> : null}
</GlassCard> </GlassCard>
); );
}; };
@@ -0,0 +1,40 @@
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,11 +1,15 @@
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};
@@ -106,6 +110,27 @@ 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);
@@ -141,15 +166,26 @@ 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 response = await axiosInstance.get<SubscriptionMe>('/finance/subscription/'); const [subscriptionResponse, invoiceResponse] = await Promise.all([
setSubscription(response.data || null); axiosInstance.get<SubscriptionMe>('/monetization/subscription/'),
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);
} }
@@ -174,6 +210,39 @@ 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>
@@ -224,9 +293,27 @@ 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>

Some files were not shown because too many files have changed in this diff Show More