1 Commits
Author SHA1 Message Date
westfarn 75a0d7f637 Add RevenueCat Capacitor IAP; keep Stripe for web billing.
Unit Tests / test (pull_request) Failing after 11s
Native builds purchase/restore via RevenueCat; browser keeps Stripe
checkout/portal. Billing history shows store ledger rows from backend
webhooks. Closes #100.
2026-08-04 03:32:25 -07:00
133 changed files with 980 additions and 5878 deletions

No files matched your search

-23
View File
@@ -1,23 +0,0 @@
---
description: Run ESLint with warnings as errors before pushing a merge request
alwaysApply: true
---
# ESLint before push
Beta deploy runs `npm run build:beta` with `CI=true`. Create React App turns ESLint warnings into build errors. Unit tests do not catch this. The failure shows up only on the deploy runner (`ai-server-4080`), where `CI=true` is set.
Before `git push` or opening a pull request that changes `llm-fe`:
1. From `llm-fe`, lint every changed file under `src` with warnings as errors:
```bash
CI=true ./node_modules/.bin/eslint --max-warnings 0 path/to/ChangedFile.tsx
```
2. Fix every warning in those files. Do not push while that command exits non-zero.
3. Do not set `DISABLE_ESLINT_PLUGIN` or `CI=false` to get the deploy through.
A full `eslint src` run reports warnings in files the production build does not compile. Those do not fail deploy. The gate is warnings in files the build compiles.
`react-hooks/exhaustive-deps` is the usual failure. Wrap the function in `useCallback` and list it in the dependency array. An empty dependency array that calls a component function fails the beta build.
-1
View File
@@ -31,7 +31,6 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
| 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
+13 -15
View File
@@ -40,13 +40,19 @@ 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`.
### Play Billing (#78)
### RevenueCat IAP (#100)
Native billing uses the local Capacitor plugin `native-store-billing` (Play Billing on Android, StoreKit on iOS). Web keeps Stripe Checkout / Customer Portal.
Native billing uses `@revenuecat/purchases-capacitor` (Capacitor 7 → package **11.x**). Web keeps Stripe Checkout / Customer Portal.
The Play product id is the plan's `store_product_id`. The backend verifies the purchase token with the Play Developer API (`GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`). More detail: [`MONETIZATION.md`](MONETIZATION.md).
Set public SDK keys in `.env.mobile` (empty placeholders OK until store apps ship):
After plugin changes: `npm run build:mobile` (runs `cap sync`).
| 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
@@ -126,20 +132,12 @@ Out of band ops (not automated here):
1. Google Play developer account
2. Privacy policy URL (product site / legal)
3. **Account deletion URL** (Data safety / Account deletion) → public page:
- Canonical: `https://hesychia.ai/account-deletion/`
- 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).
3. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView)
4. Screenshots for phone (+ tablet if targeting)
5. Upload `.aab` to **internal testing** track before production
## Related issues
- [#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)
- [#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
+26 -9
View File
@@ -1,23 +1,40 @@
# Monetization (web Stripe + native store billing)
# Monetization (web Stripe + native RevenueCat)
## Channels
| Runtime | Checkout / manage | Ledger |
|---------|-------------------|--------|
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/monetization/...` | Stripe webhooks |
| Native Capacitor | StoreKit (iOS) and Play Billing (Android) | `POST /monetization/store/confirm/` verifies the store transaction and writes the same invoice/payment tables |
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/finance/...` | Stripe webhooks |
| Native Capacitor | RevenueCat IAP (`@revenuecat/purchases-capacitor`) | RevenueCat webhooks → same invoice/payment tables |
There is no App Store, Play, or RevenueCat webhook. After the store sheet succeeds, the app sends the signed StoreKit transaction or the Play purchase token. The backend checks it before granting access. Renewals and restores send the current store entitlement again. Access ends when the verified period end passes.
FE displays whatever `/finance/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`.
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` (`app_store`, `play_store`, or legacy `revenuecat`) plus `revenuecat_store`.
## App user id
## Store account token
RevenueCat `appUserID` = Django user pk as string.
`GET /monetization/subscription/` returns `store_account_token`. The native purchase passes that value as StoreKit `appAccountToken` and Play Billing `obfuscatedAccountId`. The backend rejects a token that belongs to a different user, and rejects a transaction already linked to another account.
1. Prefer `id` from `/user/get/` (serializer returns all model fields).
2. Else JWT `user_id` claim (`SIMPLE_JWT.USER_ID_CLAIM`).
Plan rows expose `store_product_id` (the store product identifier). Configure it on `SubscriptionPlan.revenuecat_product_id`, or set `REVENUECAT_PRODUCT_PLAN_MAP` so a product id maps to a plan slug.
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
- Backend issue #78
- Issue #100 (FE) + companion backend RevenueCat webhook PR
- [`ANDROID.md`](ANDROID.md) / [`IOS.md`](IOS.md) for store builds
+3 -9
View File
@@ -107,15 +107,9 @@ Without `keystore.properties`, release builds are unsigned; debug builds do not
1. [Play Console](https://play.google.com/console) → app **Hesychia** (`ai.hesychia.chat`).
2. Complete store listing: privacy policy URL, data-safety form, screenshots.
3. Set **Account deletion URL** to the public instructions page (no sign-in required):
- `https://hesychia.ai/account-deletion/` (preferred product host)
- 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).
3. **Testing → Internal testing** → create release → upload `app-release.aab`.
4. Add testers, roll out internal track, verify on devices.
5. Promote to closed / open / production only after internal QA passes.
Each new upload needs a higher `versionCode`.
@@ -13,7 +13,6 @@ dependencies {
implementation project(':capacitor-keyboard')
implementation project(':capacitor-preferences')
implementation project(':capacitor-status-bar')
implementation project(':native-store-billing')
}
-3
View File
@@ -13,6 +13,3 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/@capaci
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':native-store-billing'
project(':native-store-billing').projectDir = new File('../plugins/native-store-billing/android')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

+4 -4
View File
@@ -283,7 +283,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -334,7 +334,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
@@ -350,7 +350,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
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\"";
@@ -370,7 +370,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = ai.hesychia.chat;
+1 -2
View File
@@ -1,6 +1,6 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '15.0'
platform :ios, '14.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
@@ -15,7 +15,6 @@ def capacitor_pods
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'NativeStoreBilling', :path => '../../plugins/native-store-billing'
end
target 'App' do
+221 -146
View File
@@ -19,9 +19,9 @@
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@heycatch/sdk": "^0.8.3",
"@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11",
"@revenuecat/purchases-capacitor": "^11.3.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
@@ -34,12 +34,12 @@
"bootstrap": "^5.3.3",
"brace-expansion": "file:vendor/brace-expansion-compat",
"chroma-js": "^3.1.2",
"docx": "^9.7.1",
"formik": "^2.4.6",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"markdown-to-jsx": "^7.7.2",
"mini.css": "^3.0.1",
"native-store-billing": "file:plugins/native-store-billing",
"papaparse": "^5.5.4",
"pdfmake": "^0.3.11",
"react-bootstrap": "^2.10.6",
@@ -64,9 +64,11 @@
"@types/lodash": "~4.17.13",
"@types/react": "^18.3.16",
"@types/react-dom": "^18.3.5",
"@types/react-google-recaptcha": "^2.1.9",
"@types/react-syntax-highlighter": "^15.5.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0",
"typescript": "^4.9.5"
}
},
@@ -2505,7 +2507,7 @@
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
@@ -2518,7 +2520,7 @@
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
@@ -3082,103 +3084,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@heycatch/sdk": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@heycatch/sdk/-/sdk-0.8.3.tgz",
"integrity": "sha512-7knqXfptgctAIHu5IXwd/WnivY+wwYAws2Xr0ZuARottBnbQteFbxZP7TyEDWCyz6A5+PcGlzlPxeTdq5uyGoA==",
"license": "MIT",
"dependencies": {
"posthog-react-native": "^4.61.1"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"expo-router": "*",
"react": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"expo-router": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@heycatch/sdk/node_modules/posthog-react-native": {
"version": "4.81.2",
"resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.81.2.tgz",
"integrity": "sha512-w9UUocpnS9u+CO+37Q32W/zSgL8/fSFhIB9tUZfSDc1D4rE7XShyfCwwXD6efjmP19Gu941kuXdRgTbuV8Mkig==",
"license": "MIT",
"dependencies": {
"@posthog/core": "^1.59.1",
"@posthog/types": "^1.416.0"
},
"bin": {
"posthog-react-native-xcode": "tooling/posthog-xcode.sh"
},
"peerDependencies": {
"@posthog/react-native-plugin": ">= 2.9.3",
"@react-native-async-storage/async-storage": ">=1.0.0",
"@react-navigation/native": ">= 5.0.0",
"expo-application": ">= 4.0.0",
"expo-device": ">= 4.0.0",
"expo-file-system": ">= 13.0.0",
"expo-localization": ">= 11.0.0",
"expo-updates": ">= 0.25.0",
"posthog-react-native-session-replay": ">= 1.6.0",
"react-native-device-info": ">= 10.0.0",
"react-native-localize": ">= 3.0.0",
"react-native-navigation": ">= 6.0.0",
"react-native-safe-area-context": ">= 4.0.0",
"react-native-svg": ">= 15.0.0"
},
"peerDependenciesMeta": {
"@posthog/react-native-plugin": {
"optional": true
},
"@react-native-async-storage/async-storage": {
"optional": true
},
"@react-navigation/native": {
"optional": true
},
"expo-application": {
"optional": true
},
"expo-device": {
"optional": true
},
"expo-file-system": {
"optional": true
},
"expo-localization": {
"optional": true
},
"expo-updates": {
"optional": true
},
"posthog-react-native-session-replay": {
"optional": true
},
"react-native-device-info": {
"optional": true
},
"react-native-localize": {
"optional": true
},
"react-native-navigation": {
"optional": true
},
"react-native-safe-area-context": {
"optional": true
},
"react-native-svg": {
"optional": true
}
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -5251,21 +5156,6 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@posthog/core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.59.1.tgz",
"integrity": "sha512-P39YgzQVDh6oz6wAzQXEnoHgTYpM7gifonmVKq1scAJco79h31XmmNVurRq9tTmmyIrr1uX8FHT0riCsyfV9AQ==",
"license": "MIT",
"dependencies": {
"@posthog/types": "^1.416.0"
}
},
"node_modules/@posthog/types": {
"version": "1.416.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.416.0.tgz",
"integrity": "sha512-0/9+02s/0uDyrH4G/qUg1yyl6gXUg8Uuxw/PlDad3KLzo9FFS9zS/kAQaEshx/saVGupj1aAy/4EUBxWisBogw==",
"license": "MIT"
},
"node_modules/@prettier/plugin-xml": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@prettier/plugin-xml/-/plugin-xml-2.2.0.tgz",
@@ -5350,6 +5240,24 @@
"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": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -5701,6 +5609,7 @@
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
@@ -6093,34 +6002,35 @@
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
@@ -6533,11 +6443,20 @@
"version": "18.3.5",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
"integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
"devOptional": true,
"dev": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-google-recaptcha": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz",
"integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-syntax-highlighter": {
"version": "15.5.13",
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
@@ -9135,7 +9054,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
@@ -10004,10 +9923,61 @@
"node": ">=6.0.0"
}
},
"node_modules/docx": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
"license": "MIT",
"dependencies": {
"@types/node": "^25.2.3",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/@types/node": {
"version": "25.9.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/dom-converter": {
@@ -12499,6 +12469,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -12895,6 +12875,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@@ -15812,6 +15798,54 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
@@ -15898,6 +15932,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
@@ -16095,6 +16138,7 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
@@ -16137,7 +16181,7 @@
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"devOptional": true,
"dev": true,
"license": "ISC"
},
"node_modules/makeerror": {
@@ -16723,10 +16767,6 @@
"node": ">= 10.x"
}
},
"node_modules/native-store-billing": {
"resolved": "plugins/native-store-billing",
"link": true
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -19051,6 +19091,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -19079,6 +19120,19 @@
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
},
"node_modules/react-async-script": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
"integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
"dev": true,
"dependencies": {
"hoist-non-react-statics": "^3.3.0",
"prop-types": "^15.5.0"
},
"peerDependencies": {
"react": ">=16.4.1"
}
},
"node_modules/react-bootstrap": {
"version": "2.10.9",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz",
@@ -19248,6 +19302,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"dev": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -19277,6 +19332,19 @@
"react": ">=16.3.0"
}
},
"node_modules/react-google-recaptcha": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
"integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
"dev": true,
"dependencies": {
"prop-types": "^15.5.0",
"react-async-script": "^1.2.0"
},
"peerDependencies": {
"react": ">=16.4.1"
}
},
"node_modules/react-is": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz",
@@ -20682,6 +20750,7 @@
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"dev": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@@ -20949,6 +21018,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -21772,12 +21847,6 @@
"postcss": "^8.2.15"
}
},
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"peer": true
},
"node_modules/stylis-plugin-rtl": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz",
@@ -22362,7 +22431,7 @@
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
@@ -22406,7 +22475,7 @@
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
@@ -22419,14 +22488,14 @@
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/ts-node/node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"devOptional": true,
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
@@ -22611,6 +22680,7 @@
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -22858,7 +22928,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
@@ -23862,11 +23932,16 @@
"node": ">=0.8"
}
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
@@ -24007,7 +24082,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -24046,16 +24121,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"plugins/native-store-billing": {
"version": "0.1.0",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"vendor/brace-expansion-compat": {
"name": "brace-expansion",
"version": "5.0.8",
"license": "MIT"
},
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"peer": true
}
}
}
+8 -6
View File
@@ -14,9 +14,9 @@
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@heycatch/sdk": "^0.8.3",
"@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11",
"@revenuecat/purchases-capacitor": "^11.3.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
@@ -29,12 +29,12 @@
"bootstrap": "^5.3.3",
"brace-expansion": "file:vendor/brace-expansion-compat",
"chroma-js": "^3.1.2",
"docx": "^9.7.1",
"formik": "^2.4.6",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"markdown-to-jsx": "^7.7.2",
"mini.css": "^3.0.1",
"native-store-billing": "file:plugins/native-store-billing",
"papaparse": "^5.5.4",
"pdfmake": "^0.3.11",
"react-bootstrap": "^2.10.6",
@@ -54,9 +54,9 @@
},
"scripts": {
"start": "NODE_ENV=development react-scripts start",
"build": "node scripts/writeSitemap.js && NODE_ENV=production react-scripts build && node scripts/stampCanonicals.js",
"build:prod": "node scripts/writeSitemap.js && NODE_ENV=production react-scripts build && node scripts/stampCanonicals.js && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/ && node scripts/pingIndexNow.js",
"build:beta": "node scripts/writeSitemap.js && bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && node scripts/stampCanonicals.js && mkdir -p /var/www/beta.chat.aimloperations/html && cp -r ./build/* /var/www/beta.chat.aimloperations/html/",
"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: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:open": "npx cap open android",
"android:sync": "npx cap sync android",
@@ -64,7 +64,7 @@
"ios:sync": "npx cap sync ios",
"assets:generate": "npx capacitor-assets generate --ios --android",
"test": "NODE_ENV=development react-scripts test",
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false && node --test scripts/stampCanonicals.test.js scripts/sitemap.test.js",
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false",
"eject": "react-scripts eject"
},
"eslintConfig": {
@@ -92,9 +92,11 @@
"@types/lodash": "~4.17.13",
"@types/react": "^18.3.16",
"@types/react-dom": "^18.3.5",
"@types/react-google-recaptcha": "^2.1.9",
"@types/react-syntax-highlighter": "^15.5.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0",
"typescript": "^4.9.5"
},
"overrides": {
@@ -1,17 +0,0 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
Pod::Spec.new do |s|
s.name = 'NativeStoreBilling'
s.version = package['version']
s.summary = package['description']
s.license = 'MIT'
s.homepage = 'https://aimloperations.com'
s.author = 'AI ML Operations'
s.source = { :path => '.' }
s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
s.ios.deployment_target = '15.0'
s.dependency 'Capacitor'
s.swift_version = '5.1'
end
@@ -1,30 +0,0 @@
apply plugin: 'com.android.library'
android {
namespace "ai.hesychia.chat.billing"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
versionCode 1
versionName "1.0"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
lintOptions {
abortOnError false
}
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation project(':capacitor-android')
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "com.android.billingclient:billing:7.1.1"
}
@@ -1,2 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -1,243 +0,0 @@
package ai.hesychia.chat.billing;
import android.app.Activity;
import com.android.billingclient.api.AcknowledgePurchaseParams;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.BillingClientStateListener;
import com.android.billingclient.api.BillingFlowParams;
import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.PendingPurchasesParams;
import com.android.billingclient.api.ProductDetails;
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchasesUpdatedListener;
import com.android.billingclient.api.QueryProductDetailsParams;
import com.android.billingclient.api.QueryPurchasesParams;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.json.JSONObject;
@CapacitorPlugin(name = "NativeStoreBilling")
public class NativeStoreBillingPlugin extends Plugin implements PurchasesUpdatedListener {
private BillingClient billingClient;
private PluginCall pendingPurchase;
private String pendingProductId = "";
@PluginMethod
public void purchase(PluginCall call) {
String productId = call.getString("productId", "");
String accountToken = call.getString("accountToken", "");
if (productId == null || productId.isEmpty()) {
call.reject("productId is required");
return;
}
Activity activity = getActivity();
if (activity == null) {
call.reject("Play Billing needs a foreground activity");
return;
}
activity.runOnUiThread(() ->
connect(call, () -> queryAndLaunch(call, productId, accountToken == null ? "" : accountToken))
);
}
@PluginMethod
public void restore(PluginCall call) {
Activity activity = getActivity();
if (activity == null) {
call.reject("Play Billing needs a foreground activity");
return;
}
activity.runOnUiThread(() -> connect(call, () -> queryOwned(call, "")));
}
private void connect(PluginCall call, Runnable whenReady) {
if (billingClient != null && billingClient.isReady()) {
whenReady.run();
return;
}
billingClient = BillingClient.newBuilder(getContext())
.setListener(this)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
)
.build();
billingClient.startConnection(
new BillingClientStateListener() {
@Override
public void onBillingSetupFinished(BillingResult billingResult) {
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
whenReady.run();
return;
}
call.reject("Play Billing setup failed: " + billingResult.getDebugMessage());
}
@Override
public void onBillingServiceDisconnected() {
// The next purchase reconnects.
}
}
);
}
private void queryAndLaunch(PluginCall call, String productId, String accountToken) {
QueryProductDetailsParams params = QueryProductDetailsParams.newBuilder()
.setProductList(
Collections.singletonList(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(BillingClient.ProductType.SUBS)
.build()
)
)
.build();
billingClient.queryProductDetailsAsync(params, (billingResult, productDetailsList) -> {
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
call.reject("Could not load Play product: " + billingResult.getDebugMessage());
return;
}
if (productDetailsList == null || productDetailsList.isEmpty()) {
call.reject("Play product was not found: " + productId);
return;
}
ProductDetails details = productDetailsList.get(0);
List<ProductDetails.SubscriptionOfferDetails> offers = details.getSubscriptionOfferDetails();
if (offers == null || offers.isEmpty()) {
call.reject("Play product has no subscription offer");
return;
}
String offerToken = offers.get(0).getOfferToken();
BillingFlowParams.ProductDetailsParams productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.setOfferToken(offerToken)
.build();
BillingFlowParams.Builder flow = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(Collections.singletonList(productParams));
if (!accountToken.isEmpty()) {
flow.setObfuscatedAccountId(accountToken);
}
Activity activity = getActivity();
if (activity == null) {
call.reject("Play Billing needs a foreground activity");
return;
}
activity.runOnUiThread(() -> {
pendingPurchase = call;
pendingProductId = productId;
BillingResult launched = billingClient.launchBillingFlow(activity, flow.build());
if (launched.getResponseCode() != BillingClient.BillingResponseCode.OK) {
pendingPurchase = null;
call.reject("Could not open Play purchase sheet: " + launched.getDebugMessage());
}
});
});
}
@Override
public void onPurchasesUpdated(BillingResult billingResult, List<Purchase> purchases) {
PluginCall call = pendingPurchase;
pendingPurchase = null;
if (call == null) {
return;
}
int code = billingResult.getResponseCode();
if (code == BillingClient.BillingResponseCode.USER_CANCELED) {
JSObject data = new JSObject();
data.put("userCancelled", true);
call.reject("Purchase cancelled", "PURCHASE_CANCELLED", null, data);
return;
}
if (code == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) {
queryOwned(call, pendingProductId);
return;
}
if (code != BillingClient.BillingResponseCode.OK || purchases == null || purchases.isEmpty()) {
call.reject("Play purchase failed: " + billingResult.getDebugMessage());
return;
}
Purchase match = null;
for (Purchase purchase : purchases) {
if (purchase.getPurchaseState() != Purchase.PurchaseState.PURCHASED) {
continue;
}
if (pendingProductId.isEmpty() || purchase.getProducts().contains(pendingProductId)) {
match = purchase;
break;
}
}
if (match == null) {
call.reject("Play purchase did not complete");
return;
}
acknowledge(match);
call.resolve(purchaseToJson(match));
}
private void queryOwned(PluginCall call, String productId) {
QueryPurchasesParams params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build();
billingClient.queryPurchasesAsync(params, (billingResult, purchases) -> {
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
call.reject("Could not restore Play purchases: " + billingResult.getDebugMessage());
return;
}
JSArray rows = new JSArray();
Purchase single = null;
List<Purchase> owned = purchases == null ? new ArrayList<>() : purchases;
for (Purchase purchase : owned) {
if (purchase.getPurchaseState() != Purchase.PurchaseState.PURCHASED) {
continue;
}
if (!productId.isEmpty() && !purchase.getProducts().contains(productId)) {
continue;
}
acknowledge(purchase);
JSONObject row = purchaseToJson(purchase);
rows.put(row);
single = purchase;
}
if (!productId.isEmpty()) {
if (single == null) {
call.reject("No active Play purchase for " + productId);
return;
}
call.resolve(purchaseToJson(single));
return;
}
JSObject result = new JSObject();
result.put("transactions", rows);
call.resolve(result);
});
}
private void acknowledge(Purchase purchase) {
if (purchase.isAcknowledged() || billingClient == null) {
return;
}
AcknowledgePurchaseParams params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.build();
billingClient.acknowledgePurchase(params, billingResult -> { });
}
private JSObject purchaseToJson(Purchase purchase) {
String productId = purchase.getProducts().isEmpty() ? "" : purchase.getProducts().get(0);
String orderId = purchase.getOrderId() == null ? purchase.getPurchaseToken() : purchase.getOrderId();
JSObject row = new JSObject();
row.put("store", "play_store");
row.put("productId", productId);
row.put("transactionId", orderId);
row.put("purchaseToken", purchase.getPurchaseToken());
row.put("signedTransaction", "");
return row;
}
}
-1
View File
@@ -1 +0,0 @@
export {};
-2
View File
@@ -1,2 +0,0 @@
// Native code is loaded by Capacitor. The web app registers the plugin from src.
module.exports = {};
@@ -1,94 +0,0 @@
import Foundation
import Capacitor
import StoreKit
@objc(NativeStoreBillingPlugin)
public class NativeStoreBillingPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "NativeStoreBillingPlugin"
public let jsName = "NativeStoreBilling"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "purchase", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "restore", returnType: CAPPluginReturnPromise)
]
@objc func purchase(_ call: CAPPluginCall) {
let productId = call.getString("productId") ?? ""
let accountToken = call.getString("accountToken") ?? ""
if productId.isEmpty {
call.reject("productId is required")
return
}
Task {
do {
let products = try await Product.products(for: [productId])
guard let product = products.first else {
call.reject("App Store product was not found: \(productId)")
return
}
var options = Set<Product.PurchaseOption>()
if let uuid = UUID(uuidString: accountToken) {
options.insert(.appAccountToken(uuid))
}
let result = try await product.purchase(options: options)
switch result {
case .success(let verification):
let transaction = try Self.checkVerified(verification)
let payload = Self.payload(transaction: transaction, jws: verification.jwsRepresentation)
await transaction.finish()
call.resolve(payload)
case .userCancelled:
call.reject("Purchase cancelled", "PURCHASE_CANCELLED", nil, ["userCancelled": true])
case .pending:
call.reject("App Store purchase is still pending")
@unknown default:
call.reject("Unknown App Store purchase result")
}
} catch {
call.reject(error.localizedDescription)
}
}
}
@objc func restore(_ call: CAPPluginCall) {
Task {
var rows: [JSObject] = []
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
if transaction.revocationDate != nil {
continue
}
rows.append(Self.payload(transaction: transaction, jws: result.jwsRepresentation))
}
call.resolve(["transactions": rows])
}
}
private static func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw NSError(
domain: "NativeStoreBilling",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "App Store could not verify this transaction"]
)
case .verified(let safe):
return safe
}
}
private static func payload(transaction: Transaction, jws: String) -> JSObject {
var row: JSObject = [
"store": "app_store",
"productId": transaction.productID,
"transactionId": String(transaction.id),
"purchaseToken": "",
"signedTransaction": jws
]
if let expires = transaction.expirationDate {
row["expiresAt"] = ISO8601DateFormatter().string(from: expires)
}
return row
}
}
@@ -1,26 +0,0 @@
{
"name": "native-store-billing",
"version": "0.1.0",
"private": true,
"description": "StoreKit and Play Billing purchases for the Hesychia Capacitor shell.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"capacitor": {
"ios": {
"src": "ios"
},
"android": {
"src": "android"
}
},
"files": [
"android/src/main/",
"android/build.gradle",
"ios/Sources",
"NativeStoreBilling.podspec",
"dist/"
],
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
}
-48
View File
@@ -1,48 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>The page is not found — Hesychia</title>
<meta name="description" content="The page is not found. This address is not a Hesychia page. Open the home page to read what the product does, what it costs, and how to start an account." />
<meta property="og:title" content="The page is not found — Hesychia" />
<meta property="og:description" content="The page is not found. This address is not a Hesychia page. Open the home page to read what the product does, what it costs, and how to start an account." />
<meta property="og:url" content="https://hesychia.ai/404.html" />
<meta property="og:image" content="https://hesychia.ai/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="The page is not found — Hesychia" />
<meta name="twitter:description" content="The page is not found. This address is not a Hesychia page. Open the home page to read what the product does, what it costs, and how to start an account." />
<meta name="twitter:image" content="https://hesychia.ai/og-image.png" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"headline": "The page is not found — Hesychia",
"url": "https://hesychia.ai/404.html",
"mainEntityOfPage": "https://hesychia.ai/404.html"
},
{
"@type": "Organization",
"name": "Hesychia",
"url": "https://hesychia.ai",
"logo": "https://hesychia.ai/apple-touch-icon.png",
"parentOrganization": {
"@type": "Organization",
"name": "AI ML Operations, LLC",
"url": "https://www.aimloperations.com"
}
}
]
}
</script>
</head>
<body>
<h1>Not Found</h1>
<p>The page is not found, and this address is not a Hesychia page.</p>
<p>Open the home page to read what the product does, what it costs, and how to start an account.</p>
<nav aria-label="Related pages"><a href="/">What Hesychia is</a><a href="/pricing/">Hesychia pricing — $10/month, no ads</a><a href="/privacy/">What Hesychia stores and does not sell</a><a href="/changelog/">Dated Hesychia product updates</a></nav>
</body>
</html>
@@ -1 +0,0 @@
8d1667dd71c6ef137171989c26eeb632
+6 -57
View File
@@ -8,62 +8,11 @@
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<meta name="description" content="Hesychia — a calm AI assistant by AI ML Operations, LLC" />
<meta name="application-name" content="Hesychia" />
<link rel="canonical" href="https://hesychia.ai/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Hesychia — quiet, focused AI chat" />
<meta property="og:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<meta property="og:url" content="https://hesychia.ai/" />
<meta property="og:image" content="https://hesychia.ai/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Hesychia — quiet, focused AI chat" />
<meta name="twitter:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<meta name="twitter:image" content="https://hesychia.ai/og-image.png" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"headline": "Hesychia — quiet, focused AI chat",
"url": "https://hesychia.ai/",
"mainEntityOfPage": "https://hesychia.ai/"
},
{
"@type": "Organization",
"name": "Hesychia",
"url": "https://hesychia.ai",
"logo": "https://hesychia.ai/apple-touch-icon.png",
"parentOrganization": {
"@type": "Organization",
"name": "AI ML Operations, LLC",
"url": "https://www.aimloperations.com"
}
},
{
"@type": "SoftwareApplication",
"name": "Hesychia",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Web",
"url": "https://hesychia.ai/",
"description": "Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files.",
"offers": {
"@type": "Offer",
"price": "10.00",
"priceCurrency": "USD"
},
"provider": {
"@type": "Organization",
"name": "AI ML Operations, LLC",
"url": "https://www.aimloperations.com"
}
}
]
}
</script>
<meta property="og:title" content="Hesychia" />
<meta property="og:description" content="Hesychia — quiet, focused AI chat at hesychia.ai" />
<meta property="og:url" content="https://hesychia.ai" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
@@ -79,11 +28,11 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Hesychia — quiet, focused AI chat</title>
<title>Hesychia</title>
</head>
<body>
<div id="root"><h1>Hesychia is a calm AI assistant.</h1><div id="crawler-prose"><p>Quiet, focused AI chat — private by default — is how this Hesychia product is built.</p><p>It remembers what you told it and your documents, so you never start over, and conversation memory is a setting you control.</p><p>We store your account, your conversations, and the documents you upload.</p><p>We do not sell your data, and we do not use advertising cookies.</p><p>Canceling does not delete documents, and they stay until you delete them or delete the account, and account deletion is a soft-delete.</p><p>It remembers what you told it and your documents, so you never start over.</p><p>Your documents stay stored and searchable, and answers are grounded in your own files.</p><p>Business accounts are simple management for your team, and company managers can manage team accounts.</p><p>Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.</p><p>Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.</p><p>Upload an image with your prompt and Hesychia reads the picture, so you can ask about what is in it.</p></div><nav aria-label="Related pages"><a href="/pricing/">Hesychia pricing — $10/month, no ads</a><a href="/privacy/">What Hesychia stores and does not sell</a><a href="/changelog/">Dated Hesychia product updates</a><a href="/terms_of_service/">Hesychia terms of service</a><a href="/account-deletion/">How to request Hesychia account deletion</a></nav></div>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
-18
View File
@@ -1,18 +0,0 @@
# Hesychia
> Hesychia is a calm AI assistant with quiet, focused AI chat that stays private by default.
Hesychia is a $10/month AI chat product from AI ML Operations, LLC. Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up. It stores your documents so answers can use your files, and company managers can manage team accounts. Conversation memory is a setting you control, and canceling a subscription does not delete documents. Account deletion is a soft-delete, and stored account data may remain. Image generation is on the roadmap. Upload an image with a prompt and Hesychia reads the picture.
## Pages
- [Home](https://hesychia.ai/): what Hesychia is, who it is for, and how to start
- [Pricing](https://hesychia.ai/pricing/): $10/month early adopter, locked for life, never goes up
- [Privacy](https://hesychia.ai/privacy/): what is stored, what is not sold, what cancel does to documents
- [Changelog](https://hesychia.ai/changelog/): dated product updates
- [Terms of Service](https://hesychia.ai/terms_of_service/): the binding terms
- [Account deletion](https://hesychia.ai/account-deletion/): how to delete an account and what soft-delete keeps
## Operator
AI ML Operations, LLC — https://www.aimloperations.com
Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

+1 -14
View File
@@ -1,16 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Allow: /
Disallow: /signin/
Disallow: /signup/
Disallow: /account/
Disallow: /document_storage
Disallow: /feedback/
Disallow: /analytics/
Disallow: /password_reset/
Disallow: /password_reset_confirmation/
Disallow: /set_password/
Disallow: /auth/
Disallow: /billing/
Sitemap: https://hesychia.ai/sitemap.xml
Disallow:
-27
View File
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://hesychia.ai/</loc>
<lastmod>2026-10-19</lastmod>
</url>
<url>
<loc>https://hesychia.ai/pricing/</loc>
<lastmod>2026-10-19</lastmod>
</url>
<url>
<loc>https://hesychia.ai/privacy/</loc>
<lastmod>2026-10-19</lastmod>
</url>
<url>
<loc>https://hesychia.ai/changelog/</loc>
<lastmod>2026-08-04</lastmod>
</url>
<url>
<loc>https://hesychia.ai/terms_of_service/</loc>
<lastmod>2026-07-27</lastmod>
</url>
<url>
<loc>https://hesychia.ai/account-deletion/</loc>
<lastmod>2026-08-04</lastmod>
</url>
</urlset>
-40
View File
@@ -1,40 +0,0 @@
/**
* One IndexNow POST for the URLs in the sitemap. Run after the prod files are published.
*/
const fs = require('fs');
const path = require('path');
const { INDEXNOW_KEY, indexNowBody, sitemapEntries } = require('./sitemap');
async function pingIndexNow(fetchImpl = fetch, urls) {
const keyPath = path.join(__dirname, '..', 'public', `${INDEXNOW_KEY}.txt`);
const key = fs.readFileSync(keyPath, 'utf8').trim();
if (key !== INDEXNOW_KEY) {
throw new Error(`IndexNow key file does not match ${INDEXNOW_KEY}`);
}
const urlList = urls || sitemapEntries().map((entry) => entry.loc);
const body = indexNowBody(key, urlList);
const response = await fetchImpl('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await response.text();
if (response.status !== 200 && response.status !== 202) {
throw new Error(`IndexNow ${response.status}: ${text}`);
}
return { status: response.status, body };
}
async function main() {
const result = await pingIndexNow();
process.stdout.write(`IndexNow ${result.status}\n`);
}
module.exports = { pingIndexNow };
if (require.main === module) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}
-153
View File
@@ -1,153 +0,0 @@
/**
* Sitemap lastmod comes from the page source, not from the build clock.
* Changelog uses the newest entry date. Terms use the "Last Updated" line.
* Other public pages use the last git commit that touched that file.
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const ORIGIN = 'https://hesychia.ai';
const INDEXNOW_KEY = '8d1667dd71c6ef137171989c26eeb632';
const INDEXNOW_HOST = 'hesychia.ai';
const llmFe = path.resolve(__dirname, '..');
const repoRoot = path.resolve(llmFe, '..');
/** Public pages only. Same URLs the site already lists for crawlers. */
const SITEMAP_PAGES = [
{
path: '/',
file: 'llm-fe/src/llm-fe/pages/Landing/LandingPage.tsx',
lastmod: 'git',
},
{
path: '/pricing/',
file: 'llm-fe/src/llm-fe/pages/Pricing/PricingPage.tsx',
lastmod: 'git',
},
{
path: '/privacy/',
file: 'llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.tsx',
lastmod: 'git',
},
{
path: '/changelog/',
file: 'llm-fe/src/llm-fe/pages/Changelog/changelogEntries.ts',
lastmod: 'changelog',
},
{
path: '/terms_of_service/',
file: 'llm-fe/src/llm-fe/components/legal/TermsOfServiceDocument.tsx',
lastmod: 'terms',
},
{
path: '/account-deletion/',
file: 'llm-fe/src/llm-fe/pages/AccountDeletion/AccountDeletionPage.tsx',
lastmod: 'git',
},
];
const MONTHS = {
January: '01',
February: '02',
March: '03',
April: '04',
May: '05',
June: '06',
July: '07',
August: '08',
September: '09',
October: '10',
November: '11',
December: '12',
};
function newestChangelogDate(source) {
const dates = [...source.matchAll(/date:\s*'(\d{4}-\d{2}-\d{2})'/g)].map((match) => match[1]);
if (dates.length === 0) {
throw new Error('changelog entries have no dates');
}
return dates.sort().at(-1);
}
function termsUpdated(source) {
const match = source.match(/Last Updated:\s+([A-Z][a-z]+)\s+(\d{1,2}),\s+(\d{4})/);
if (!match) {
throw new Error('terms have no Last Updated date');
}
const month = MONTHS[match[1]];
if (!month) {
throw new Error(`unknown month in terms date: ${match[1]}`);
}
return `${match[3]}-${month}-${match[2].padStart(2, '0')}`;
}
function gitLastmod(file) {
const out = execFileSync('git', ['log', '-1', '--format=%cs', '--', file], {
cwd: repoRoot,
encoding: 'utf8',
}).trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(out)) {
throw new Error(`no git commit date for ${file}`);
}
return out;
}
function lastmodFor(page) {
const abs = path.join(repoRoot, page.file);
const source = fs.readFileSync(abs, 'utf8');
if (page.lastmod === 'changelog') return newestChangelogDate(source);
if (page.lastmod === 'terms') return termsUpdated(source);
return gitLastmod(page.file);
}
function sitemapEntries() {
return SITEMAP_PAGES.map((page) => ({
loc: `${ORIGIN}${page.path}`,
lastmod: lastmodFor(page),
}));
}
function renderSitemap(entries) {
const urls = entries
.map(
(entry) => ` <url>
<loc>${entry.loc}</loc>
<lastmod>${entry.lastmod}</lastmod>
</url>`,
)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>
`;
}
function writeSitemap() {
const xml = renderSitemap(sitemapEntries());
fs.writeFileSync(path.join(llmFe, 'public', 'sitemap.xml'), xml);
return xml;
}
function indexNowBody(key, urls) {
return {
host: INDEXNOW_HOST,
key,
keyLocation: `https://${INDEXNOW_HOST}/${key}.txt`,
urlList: urls,
};
}
module.exports = {
INDEXNOW_KEY,
SITEMAP_PAGES,
newestChangelogDate,
termsUpdated,
gitLastmod,
sitemapEntries,
renderSitemap,
writeSitemap,
indexNowBody,
};
-87
View File
@@ -1,87 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const {
newestChangelogDate,
termsUpdated,
renderSitemap,
gitLastmod,
indexNowBody,
INDEXNOW_KEY,
} = require('./sitemap');
const { pingIndexNow } = require('./pingIndexNow');
const repoRoot = path.resolve(__dirname, '../..');
test('changelog lastmod is the newest entry date', () => {
const source = `
{ date: '2026-08-01', title: 'Documents' },
{ date: '2026-08-04', title: 'Account deletion' },
`;
assert.equal(newestChangelogDate(source), '2026-08-04');
});
test('terms lastmod is the Last Updated line, not today', () => {
assert.equal(
termsUpdated('Last Updated: July 27, 2026'),
'2026-07-27',
);
});
test('live page sources supply lastmod', () => {
const changelog = fs.readFileSync(
path.join(repoRoot, 'llm-fe/src/llm-fe/pages/Changelog/changelogEntries.ts'),
'utf8',
);
const terms = fs.readFileSync(
path.join(repoRoot, 'llm-fe/src/llm-fe/components/legal/TermsOfServiceDocument.tsx'),
'utf8',
);
assert.equal(newestChangelogDate(changelog), '2026-08-04');
assert.equal(termsUpdated(terms), '2026-07-27');
for (const file of [
'llm-fe/src/llm-fe/pages/Landing/LandingPage.tsx',
'llm-fe/src/llm-fe/pages/Pricing/PricingPage.tsx',
'llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.tsx',
'llm-fe/src/llm-fe/pages/AccountDeletion/AccountDeletionPage.tsx',
]) {
try {
assert.match(gitLastmod(file), /^\d{4}-\d{2}-\d{2}$/);
} catch (error) {
if (!String(error.message).includes('no git commit date')) throw error;
}
}
const xml = renderSitemap([
{ loc: 'https://hesychia.ai/changelog/', lastmod: newestChangelogDate(changelog) },
{ loc: 'https://hesychia.ai/terms_of_service/', lastmod: termsUpdated(terms) },
]);
assert.match(xml, /<loc>https:\/\/hesychia\.ai\/changelog\/<\/loc>\n\s*<lastmod>2026-08-04<\/lastmod>/);
assert.match(xml, /<lastmod>2026-07-27<\/lastmod>/);
assert.equal(xml.includes('<html'), false);
});
test('IndexNow POST body lists the published URLs and the hosted key', async () => {
const urls = ['https://hesychia.ai/', 'https://hesychia.ai/pricing/'];
assert.deepEqual(indexNowBody(INDEXNOW_KEY, urls), {
host: 'hesychia.ai',
key: INDEXNOW_KEY,
keyLocation: `https://hesychia.ai/${INDEXNOW_KEY}.txt`,
urlList: urls,
});
let posted;
const result = await pingIndexNow(async (url, init) => {
posted = { url, init };
return { status: 202, text: async () => '' };
}, urls);
assert.equal(posted.url, 'https://api.indexnow.org/indexnow');
assert.equal(posted.init.method, 'POST');
assert.equal(posted.init.headers['Content-Type'], 'application/json');
const body = JSON.parse(posted.init.body);
assert.equal(body.keyLocation, `https://hesychia.ai/${INDEXNOW_KEY}.txt`);
assert.deepEqual(body.urlList, urls);
assert.equal(result.status, 202);
});
-175
View File
@@ -1,175 +0,0 @@
/**
* CRA serves one index.html for every route. A canonical in that shared file
* would point every page at the homepage. After `react-scripts build`, write a
* copy per route whose canonical href is that route's own absolute URL.
*/
const fs = require('fs');
const path = require('path');
const { ORIGIN, OG_IMAGE, PAGE_PATHS, h1ForPath, titleForPath, descriptionForPath } = require('../src/llm-fe/utils/canonicals');
const { relatedNavHtml } = require('../src/llm-fe/utils/internalLinks');
const { proseHtml } = require('../src/llm-fe/utils/pageProse');
const { jsonLdHtml } = require('../src/llm-fe/utils/structuredData');
const CANONICAL_RE = /<link\s+rel=["']canonical["']\s+href=["'][^"']*["']\s*\/?>/i;
const H1_RE = /<h1[^>]*>[\s\S]*?<\/h1>/i;
const TITLE_RE = /<title>[^<]*<\/title>/i;
const DESCRIPTION_RE = /<meta\s+name=["']description["']\s+content=["'][^"']*["']\s*\/?>/i;
const RELATED_NAV_RE = /<nav\s+aria-label=["']Related pages["']>[\s\S]*?<\/nav>/i;
const PROSE_RE = /<div id="crawler-prose">[\s\S]*?<\/div>/i;
const JSONLD_SOURCE = '<script type="application\\/ld\\+json">[\\s\\S]*?<\\/script>';
/**
* @param {string} html
* @param {'property' | 'name'} attr
* @param {string} key
* @param {string} content
* @returns {string}
*/
function replaceSocial(html, attr, key, content) {
const re = new RegExp(
`<meta\\s+${attr}=["']${key}["']\\s+content=["'][^"']*["']\\s*\\/?>`,
'i',
);
if (!re.test(html)) {
throw new Error(`missing meta ${attr}="${key}"`);
}
return html.replace(re, () => `<meta ${attr}="${key}" content="${content}" />`);
}
/**
* @param {string} homeHtml
* @param {string} pathname
* @returns {string}
*/
function htmlForPath(homeHtml, pathname) {
const found = homeHtml.match(new RegExp(CANONICAL_RE.source, 'gi'));
if (!found || found.length !== 1) {
throw new Error(
`expected exactly one <link rel="canonical"> in index.html, found ${found ? found.length : 0}`,
);
}
const url = `${ORIGIN}${pathname}`;
let next = homeHtml.replace(CANONICAL_RE, `<link rel="canonical" href="${url}" />`);
const h1s = next.match(/<h1\b/gi);
if (!h1s || h1s.length !== 1) {
throw new Error(`expected exactly one <h1> in index.html, found ${h1s ? h1s.length : 0}`);
}
const headline = h1ForPath(pathname);
if (!headline) {
throw new Error(`no h1 for ${pathname}`);
}
next = next.replace(H1_RE, `<h1>${headline}</h1>`);
if (next.match(/<h1\b/gi).length !== 1) {
throw new Error(`stamped ${pathname} does not have exactly one h1`);
}
const titles = next.match(/<title\b/gi);
if (!titles || titles.length !== 1) {
throw new Error(`expected exactly one <title> in index.html, found ${titles ? titles.length : 0}`);
}
const title = titleForPath(pathname);
if (!title) {
throw new Error(`no title for ${pathname}`);
}
next = next.replace(TITLE_RE, `<title>${title}</title>`);
if (next.match(/<title\b/gi).length !== 1) {
throw new Error(`stamped ${pathname} does not have exactly one title`);
}
const descriptions = next.match(/<meta\s+name=["']description["']/gi);
if (!descriptions || descriptions.length !== 1) {
throw new Error(
`expected exactly one meta description in index.html, found ${descriptions ? descriptions.length : 0}`,
);
}
const description = descriptionForPath(pathname);
if (!description) {
throw new Error(`no description for ${pathname}`);
}
next = next.replace(DESCRIPTION_RE, `<meta name="description" content="${description}" />`);
if (next.match(/<meta\s+name=["']description["']/gi).length !== 1) {
throw new Error(`stamped ${pathname} does not have exactly one meta description`);
}
next = replaceSocial(next, 'property', 'og:title', title);
next = replaceSocial(next, 'property', 'og:description', description);
next = replaceSocial(next, 'property', 'og:url', url);
next = replaceSocial(next, 'name', 'twitter:title', title);
next = replaceSocial(next, 'name', 'twitter:description', description);
if (!next.includes(`property="og:image" content="${OG_IMAGE}"`)) {
throw new Error(`stamped ${pathname} is missing the 1200x630 og:image`);
}
if (!next.includes('name="twitter:card" content="summary_large_image"')) {
throw new Error(`stamped ${pathname} is missing twitter:card`);
}
const prose = proseHtml(pathname);
if (PROSE_RE.test(next)) {
next = next.replace(PROSE_RE, () => prose);
} else {
next = next.replace(/<\/h1>/i, () => `</h1>${prose}`);
}
const nav = relatedNavHtml(pathname);
if (RELATED_NAV_RE.test(next)) {
next = next.replace(RELATED_NAV_RE, () => nav);
} else {
next = next.replace(/<\/h1>/i, () => `</h1>${nav}`);
}
if (next.match(/<nav\s+aria-label=["']Related pages["']/gi)?.length !== 1) {
throw new Error(`stamped ${pathname} does not have exactly one related-pages nav`);
}
const block = jsonLdHtml(pathname);
const jsonLdRe = new RegExp(JSONLD_SOURCE, 'gi');
const existing = next.match(jsonLdRe);
if (existing) {
let replaced = false;
next = next.replace(new RegExp(JSONLD_SOURCE, 'gi'), () => {
if (replaced) return '';
replaced = true;
return block;
});
} else {
next = next.replace(/<\/head>/i, () => `${block}\n</head>`);
}
const scripts = next.match(/<script type="application\/ld\+json">/gi);
if (!scripts || scripts.length !== 1) {
throw new Error(`stamped ${pathname} should have one JSON-LD script, found ${scripts ? scripts.length : 0}`);
}
const parsed = JSON.parse(next.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/i)[1]);
const page = parsed['@graph'] ? parsed['@graph'].find((node) => node['@type'] === 'WebPage') : parsed;
if (!page || page.headline !== title) {
throw new Error(`JSON-LD headline for ${pathname} does not match the title`);
}
return next;
}
/**
* @param {string} buildDir
* @param {string} pathname
* @returns {string}
*/
function destForPath(buildDir, pathname) {
if (pathname === '/') return path.join(buildDir, 'index.html');
if (pathname.endsWith('/')) {
return path.join(buildDir, pathname.slice(1), 'index.html');
}
// Exact URI with no extension. nginx serves these with default_type text/html
// so /document_storage is this file, not the SPA fallback.
return path.join(buildDir, pathname.slice(1));
}
/**
* @param {string} buildDir
*/
function stampCanonicals(buildDir) {
const indexPath = path.join(buildDir, 'index.html');
const homeHtml = fs.readFileSync(indexPath, 'utf8');
for (const pathname of PAGE_PATHS) {
const dest = destForPath(buildDir, pathname);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, htmlForPath(homeHtml, pathname));
}
}
module.exports = { htmlForPath, destForPath, stampCanonicals, CANONICAL_RE };
if (require.main === module) {
const buildDir = process.argv[2] || path.resolve(__dirname, '../build');
stampCanonicals(buildDir);
}
-134
View File
@@ -1,134 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { PAGE_PATHS, h1ForPath, titleForPath, descriptionForPath } = require('../src/llm-fe/utils/canonicals');
const { htmlForPath, stampCanonicals } = require('./stampCanonicals');
const HOME = `<!DOCTYPE html><html><head>
<link rel="canonical" href="https://hesychia.ai/" />
<meta property="og:title" content="Hesychia — quiet, focused AI chat" />
<meta property="og:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<meta property="og:url" content="https://hesychia.ai/" />
<meta property="og:image" content="https://hesychia.ai/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Hesychia — quiet, focused AI chat" />
<meta name="twitter:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<meta name="twitter:image" content="https://hesychia.ai/og-image.png" />
<meta name="description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<title>Hesychia — quiet, focused AI chat</title>
</head><body><div id="root"><h1>Hesychia is a calm AI assistant.</h1></div></body></html>`;
test('each page canonical is that page, and other head tags stay', () => {
const html = htmlForPath(HOME, '/pricing/');
assert.match(html, /<link rel="canonical" href="https:\/\/hesychia\.ai\/pricing\/" \/>/);
assert.equal(html.match(/rel="canonical"/g).length, 1);
assert.match(html, /property="og:url" content="https:\/\/hesychia\.ai\/pricing\/"/);
assert.match(html, /property="og:title" content="Hesychia pricing — \$10\/month, no ads"/);
assert.match(html, /property="og:description" content="\$10\/month, flat\. No ads, no upsells, one price\./);
assert.match(html, /property="og:image" content="https:\/\/hesychia\.ai\/og-image\.png"/);
assert.match(html, /name="twitter:card" content="summary_large_image"/);
assert.match(html, /name="twitter:title" content="Hesychia pricing — \$10\/month, no ads"/);
const nav = html.match(/<nav aria-label="Related pages">([\s\S]*?)<\/nav>/);
assert.ok(nav);
const anchors = nav[1].match(/<a href=/g);
assert.ok(anchors && anchors.length >= 2 && anchors.length <= 5);
assert.match(nav[1], /href="\/">What Hesychia is</);
assert.doesNotMatch(nav[1], /href="\/pricing\/"/);
assert.doesNotMatch(nav[1], />here</);
assert.match(html, /id="crawler-prose"/);
assert.equal(html.match(/application\/ld\+json/g).length, 1);
const pricingLd = JSON.parse(html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
const pricingPage = pricingLd['@graph'].find((node) => node['@type'] === 'WebPage');
assert.equal(pricingPage.headline, 'Hesychia pricing — $10/month, no ads');
assert.equal(pricingPage.url, 'https://hesychia.ai/pricing/');
assert.match(html, /\$10\/month, flat, with no ads, no upsells, and one price/);
assert.doesNotMatch(
html.match(/<div id="crawler-prose">([\s\S]*?)<\/div>/)[1],
/Quiet, focused AI chat/,
);
assert.match(html, /<title>Hesychia pricing — \$10\/month, no ads<\/title>/);
assert.equal(html.match(/<title\b/g).length, 1);
assert.match(html, /name="description" content="\$10\/month, flat\. No ads, no upsells, one price\./);
assert.equal(html.match(/name="description"/g).length, 1);
assert.equal(html.match(/<h1\b/g).length, 1);
assert.match(html, /<h1>Pricing<\/h1>/);
});
test('stamp writes a shell per route and leaves the homepage self-referencing', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'canon-'));
fs.writeFileSync(path.join(dir, 'index.html'), HOME);
stampCanonicals(dir);
const home = fs.readFileSync(path.join(dir, 'index.html'), 'utf8');
assert.match(home, /href="https:\/\/hesychia\.ai\/"/);
const homeLd = JSON.parse(home.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
const homeOrg = homeLd['@graph'].find((node) => node['@type'] === 'Organization' && node.name === 'Hesychia');
assert.equal(homeOrg.url, 'https://hesychia.ai');
assert.equal(homeOrg.logo, 'https://hesychia.ai/apple-touch-icon.png');
assert.doesNotMatch(home, /href="https:\/\/hesychia\.ai\/pricing\/"/);
assert.equal(home.match(/<h1\b/g).length, 1);
assert.match(home, /<h1>Hesychia is a calm AI assistant.<\/h1>/);
const titles = PAGE_PATHS.map((pathname) => titleForPath(pathname));
assert.equal(new Set(titles).size, titles.length);
const descriptions = PAGE_PATHS.map((pathname) => descriptionForPath(pathname));
assert.equal(new Set(descriptions).size, descriptions.length);
for (const pathname of PAGE_PATHS) {
if (pathname === '/') continue;
const rel = pathname.endsWith('/')
? path.join(pathname.slice(1), 'index.html')
: pathname.slice(1);
const html = fs.readFileSync(path.join(dir, rel), 'utf8');
const title = titleForPath(pathname);
assert.match(html, new RegExp(`href="https://hesychia\\.ai${pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
assert.match(html, /name="twitter:card" content="summary_large_image"/);
const related = html.match(/<nav aria-label="Related pages">([\s\S]*?)<\/nav>/);
assert.ok(related, `${pathname} missing related nav`);
const relatedAnchors = related[1].match(/<a href=/g);
assert.ok(
relatedAnchors && relatedAnchors.length >= 2 && relatedAnchors.length <= 5,
`${pathname} related link count`,
);
assert.doesNotMatch(related[1], new RegExp(`href="${pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
assert.match(html, /property="og:image" content="https:\/\/hesychia\.ai\/og-image\.png"/);
assert.match(
html,
new RegExp(`property="og:title" content="${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`),
);
assert.equal(html.match(/rel="canonical"/g).length, 1);
assert.equal(html.match(/<h1\b/g).length, 1);
assert.match(html, new RegExp(`<h1>${h1ForPath(pathname).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</h1>`));
assert.ok(title.length >= 30 && title.length <= 60, `${pathname} title length ${title.length}`);
assert.match(html, new RegExp(`<title>${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</title>`));
assert.equal(html.match(/application\/ld\+json/g).length, 1);
const ld = JSON.parse(html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
const webPage = ld['@graph'].find((node) => node['@type'] === 'WebPage');
assert.equal(webPage.headline, title, `${pathname} JSON-LD headline`);
const org = ld['@graph'].find((node) => node['@type'] === 'Organization' && node.name === 'Hesychia');
assert.ok(org, `${pathname} missing Organization`);
assert.equal(org.url, 'https://hesychia.ai');
assert.equal(org.logo, 'https://hesychia.ai/apple-touch-icon.png');
if (pathname === '/changelog/') {
const posts = ld['@graph'].filter((node) => node['@type'] === 'BlogPosting');
assert.ok(posts.length >= 2);
for (const post of posts) {
assert.ok(post.headline);
assert.equal(post.author.name, 'AI ML Operations, LLC');
assert.match(post.datePublished, /^\d{4}-\d{2}-\d{2}$/);
assert.equal(post.dateModified, post.datePublished);
}
}
const description = descriptionForPath(pathname);
assert.ok(
description.length >= 110 && description.length <= 160,
`${pathname} description length ${description.length}`,
);
assert.match(
html,
new RegExp(`content="${description.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`),
);
}
});
-3
View File
@@ -1,3 +0,0 @@
const { writeSitemap } = require('./sitemap');
writeSitemap();
+2 -13
View File
@@ -11,8 +11,8 @@ import NotFound from './llm-fe/pages/NotFound/NotFound';
import { AuthContext } from './llm-fe/contexts/AuthContext';
import TermsOfService from './llm-fe/pages/TermsOfService/TermsOfService';
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 AsyncDashboard2 from './llm-fe/pages/AsyncDashboard2/AsyncDashboard2';
import FeedbackPage2 from './llm-fe/pages/FeedbackPage2/FeedbackPage2';
import DocumentStoragePage from './llm-fe/pages/DocumentStoragePage/DocumentStoragePage';
import Account2 from './llm-fe/pages/Account2/Account2';
@@ -25,11 +25,6 @@ import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/A
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary';
import ToastHost from './llm-fe/components/ToastHost/ToastHost';
import HomeRoute from './llm-fe/components/HomeRoute/HomeRoute';
import RouteMeta from './llm-fe/components/RouteMeta/RouteMeta';
import PricingPage from './llm-fe/pages/Pricing/PricingPage';
import PrivacyPage from './llm-fe/pages/Privacy/PrivacyPage';
import ChangelogPage from './llm-fe/pages/Changelog/ChangelogPage';
const ProtectedRoutes = () => {
const { authenticated, loading } = useContext(AuthContext);
@@ -56,7 +51,6 @@ class App extends Component {
<AnalyticsSession />
<ToastHost />
<AnalyticsConsentBanner />
<RouteMeta />
<div className='site'>
<main>
<div className="main-container">
@@ -77,14 +71,9 @@ class App extends Component {
{/* Public legal pages (also used for post-login ToS acknowledge) */}
<Route path={"/terms_of_service/"} Component={TermsOfService} />
<Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} />
{/* Play Console account-deletion URL (#103) — must stay public */}
<Route path={"/account-deletion/"} Component={AccountDeletionPage} />
<Route path={"/"} Component={HomeRoute} />
<Route path={"/pricing/"} Component={PricingPage} />
<Route path={"/privacy/"} Component={PrivacyPage} />
<Route path={"/changelog/"} Component={ChangelogPage} />
<Route element={<ProtectedRoutes />}>
<Route path={"/"} index={true} Component={AsyncDashboard2} />
<Route path={"/account/"} Component={Account2} />
<Route path={"/document_storage"} Component={DocumentStoragePage} />
<Route path={"/feedback/"} Component={FeedbackPage2} />
+5 -14
View File
@@ -11,7 +11,6 @@ import {
isTokenNotValidError,
} from "./llm-fe/auth/jwtHelpers";
import { redirectToAppPath } from "./llm-fe/platform/nativePlatform";
import { shouldRedirectToSignIn } from "./llm-fe/auth/authRedirect";
const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
@@ -98,10 +97,8 @@ axiosInstance.interceptors.response.use(
if (error.response.status === 401 && isRefreshCall) {
await clearTokens();
applyAccessToken(null);
// Stay on the product page. Only bounce protected app routes.
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
// Hash-safe on Capacitor (#24); plain path on web BrowserRouter.
redirectToAppPath("/signin/");
return Promise.reject(error);
}
@@ -112,9 +109,7 @@ axiosInstance.interceptors.response.use(
if (!refresh_token) {
await clearTokens();
applyAccessToken(null);
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
redirectToAppPath("/signin/");
return Promise.reject(error);
}
@@ -132,18 +127,14 @@ axiosInstance.interceptors.response.use(
} catch (err) {
await clearTokens();
applyAccessToken(null);
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
redirectToAppPath("/signin/");
return Promise.reject(err);
}
}
await clearTokens();
applyAccessToken(null);
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
redirectToAppPath("/signin/");
}
return Promise.reject(error);
-1
View File
@@ -1,4 +1,3 @@
import './llm-fe/utils/heycatch';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
@@ -1,15 +0,0 @@
import { shouldRedirectToSignIn } from './authRedirect';
describe('shouldRedirectToSignIn', () => {
it('keeps a logged-out visitor on the product page', () => {
expect(shouldRedirectToSignIn('/public/usage/', '/')).toBe(false);
expect(shouldRedirectToSignIn('/public/settings/', '/pricing/')).toBe(false);
expect(shouldRedirectToSignIn('/user/get/', '/')).toBe(false);
expect(shouldRedirectToSignIn('/user/get/', '/changelog/')).toBe(false);
});
it('still sends an expired session on the app to sign in', () => {
expect(shouldRedirectToSignIn('/user/get/', '/account/')).toBe(true);
expect(shouldRedirectToSignIn('/token/refresh/', '/document_storage')).toBe(true);
});
});
-38
View File
@@ -1,38 +0,0 @@
/** Public marketing routes. An auth failure here must not replace the page with Sign In. */
const PUBLIC_PATHS = new Set([
"/",
"/pricing",
"/privacy",
"/changelog",
"/terms_of_service",
"/legal/terms-of-service",
"/account-deletion",
"/signin",
"/signup",
"/password_reset",
"/password_reset_confirmation",
"/set_password",
]);
export function normalizeAppPath(pathname: string | null | undefined): string {
if (!pathname || pathname === "/") return "/";
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
export function isPublicApiRequest(requestUrl: string | null | undefined): boolean {
return String(requestUrl || "").includes("/public/");
}
export function isPublicAppPath(pathname: string | null | undefined): boolean {
return PUBLIC_PATHS.has(normalizeAppPath(pathname));
}
export function shouldRedirectToSignIn(
requestUrl: string | null | undefined,
pathname: string | null | undefined,
): boolean {
if (isPublicApiRequest(requestUrl)) return false;
if (isPublicAppPath(pathname)) return false;
return true;
}
@@ -1,134 +0,0 @@
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import ActivityIndicator from './ActivityIndicator';
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
const theme = {
main: '#336699',
focus: '#224466',
darkMode: true,
colors: {
text: '#ffffff',
cardBackground: 'rgba(0,0,0,0.3)',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const renderIndicator = (
props: React.ComponentProps<typeof ActivityIndicator>,
) =>
render(
<ThemeProvider theme={theme as never}>
<ActivityIndicator {...props} />
</ThemeProvider>,
);
describe('ActivityIndicator (#96)', () => {
it('has status role and polite live region regardless of state', () => {
renderIndicator({ stage: null });
const status = screen.getByRole('status');
expect(status).toHaveAttribute('aria-live', 'polite');
});
it('falls back to three dots when stage is null', () => {
renderIndicator({ stage: null });
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
});
it('renders the label verbatim for unknown/future stage values', () => {
renderIndicator({ stage: 'some_future_stage', label: 'Doing something new' });
expect(screen.getByText('Doing something new')).toBeInTheDocument();
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
});
it('shows optional detail text', () => {
renderIndicator({
stage: 'searching',
label: 'Searching the web',
detail: 'query: best pizza in town',
});
expect(screen.getByText('Searching the web')).toBeInTheDocument();
expect(screen.getByText('query: best pizza in town')).toBeInTheDocument();
});
it('renders completed stages above with check marks', () => {
const history: ActivityHistoryEntry[] = [
{ stage: 'searching', label: 'Searched the web', startedAt: 0, finishedAt: 100 },
{ stage: 'reading', label: 'Read 3 sources', startedAt: 100, finishedAt: 400 },
];
renderIndicator({
stage: 'writing',
label: null,
history,
});
expect(screen.getByText('Searched the web')).toBeInTheDocument();
expect(screen.getByText('Read 3 sources')).toBeInTheDocument();
expect(screen.getAllByText('✓')).toHaveLength(2);
});
it('hides everything when interrupted', () => {
const { container } = renderIndicator({
stage: 'searching',
label: 'Searching the web',
interrupted: true,
});
expect(container).toBeEmptyDOMElement();
});
it('shows a generic label when stage is present but label is cleared (writing stage)', () => {
renderIndicator({ stage: 'writing', label: null });
expect(screen.getByText('Working…')).toBeInTheDocument();
});
describe('prefers-reduced-motion', () => {
const originalMatchMedia = window.matchMedia;
afterEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
});
it('disables the spinner animation when the user prefers reduced motion', () => {
const matchMediaMock = jest.fn().mockImplementation((query: string) => ({
matches: query.includes('prefers-reduced-motion'),
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
}));
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: matchMediaMock,
});
renderIndicator({ stage: 'searching', label: 'Searching the web' });
const spinner = screen.getByTestId('activity-spinner');
expect(spinner).toHaveAttribute('data-reduced-motion', 'true');
});
});
it('shows elapsed time after ~10s and "Still working…" after ~30s of silence', () => {
jest.useFakeTimers();
try {
renderIndicator({ stage: 'searching', label: 'Searching the web' });
act(() => {
jest.advanceTimersByTime(11_000);
});
expect(screen.getByText('11s')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(20_000);
});
expect(screen.getByText('Still working…')).toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
});
@@ -1,346 +0,0 @@
import React, { useEffect, useReducer, useRef, useState } from 'react';
import styled, { css, keyframes } from 'styled-components';
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
/** Minimum time a single stage stays visible before advancing to a queued update (#96). */
const MIN_DISPLAY_MS = 400;
const ELAPSED_THRESHOLD_MS = 10_000;
const SILENCE_THRESHOLD_MS = 30_000;
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)')?.matches ?? false;
});
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
if (!mql) return undefined;
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}
// Safari < 14 fallback
mql.addListener(handler);
return () => mql.removeListener(handler);
}, []);
return reduced;
}
type QueueItem = { stage: string; label: string | null; detail: string | null };
type IndicatorState = {
current: QueueItem | null;
queue: QueueItem[];
lastIncomingStage: string | null;
};
type IndicatorAction =
| { type: 'INCOMING'; item: QueueItem | null }
| { type: 'ADVANCE' }
| { type: 'RESET' };
const initialIndicatorState: IndicatorState = {
current: null,
queue: [],
lastIncomingStage: null,
};
function indicatorReducer(state: IndicatorState, action: IndicatorAction): IndicatorState {
switch (action.type) {
case 'RESET':
return initialIndicatorState;
case 'INCOMING': {
if (!action.item) return initialIndicatorState;
// Same stage as the last update seen — refresh content in place (label/detail tweak),
// no need to enforce the min-display queue since it isn't a stage transition.
if (action.item.stage === state.lastIncomingStage) {
if (state.queue.length > 0) {
const queue = [...state.queue];
queue[queue.length - 1] = action.item;
return { ...state, queue };
}
return { ...state, current: action.item };
}
if (!state.current) {
return { current: action.item, queue: [], lastIncomingStage: action.item.stage };
}
return {
...state,
queue: [...state.queue, action.item],
lastIncomingStage: action.item.stage,
};
}
case 'ADVANCE': {
if (state.queue.length === 0) return state;
const [next, ...rest] = state.queue;
return { ...state, current: next, queue: rest };
}
default:
return state;
}
}
const spin = keyframes`
to { transform: rotate(360deg); }
`;
const bounce = keyframes`
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
`;
const Root = styled.div`
display: flex;
flex-direction: column;
gap: 0.35rem;
max-width: 100%;
min-width: 0;
width: 100%;
color: ${({ theme }) => theme.colors.text};
font-size: 0.9rem;
@media (max-width: 360px) {
font-size: 0.85rem;
}
`;
const HistoryList = styled.ul`
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.2rem;
opacity: 0.6;
min-width: 0;
`;
const HistoryItem = styled.li`
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
min-width: 0;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
`;
const CheckMark = styled.span`
flex-shrink: 0;
color: ${({ theme }) => (theme.darkMode ? '#8ee6a0' : '#2e7d32')};
font-weight: 700;
`;
const CurrentRow = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
`;
const Spinner = styled.span<{ $reducedMotion: boolean }>`
flex-shrink: 0;
width: 14px;
height: 14px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
opacity: 0.7;
${({ $reducedMotion }) =>
$reducedMotion
? css`
animation: none;
`
: css`
animation: ${spin} 0.8s linear infinite;
`}
`;
const TextColumn = styled.div`
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
`;
const LabelText = styled.span`
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
`;
const DetailText = styled.span`
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
opacity: 0.65;
margin-top: 0.1rem;
`;
const MetaText = styled.span`
flex-shrink: 0;
font-size: 0.75rem;
opacity: 0.55;
white-space: nowrap;
`;
const DotsRow = styled.div`
display: flex;
align-items: center;
padding: 0.15rem 0;
`;
const Dot = styled.span<{ $delay: string; $reducedMotion: boolean }>`
width: 8px;
height: 8px;
margin: 0 4px;
border-radius: 50%;
background: currentColor;
${({ $reducedMotion, $delay }) =>
$reducedMotion
? css`
opacity: 0.6;
`
: css`
animation: ${bounce} 1.4s infinite ease-in-out both;
animation-delay: ${$delay};
`}
`;
export type ActivityIndicatorProps = {
/** Current stage key from the latest "status" frame, or null before the first update. */
stage: string | null;
label?: string | null;
detail?: string | null;
/** Previously completed stages for this turn, oldest first. */
history?: ActivityHistoryEntry[];
/** Hides the indicator entirely once a stream is interrupted (#96). */
interrupted?: boolean;
};
const ActivityIndicator = ({
stage,
label = null,
detail = null,
history = [],
interrupted = false,
}: ActivityIndicatorProps): JSX.Element | null => {
const [state, dispatch] = useReducer(indicatorReducer, initialIndicatorState);
const reducedMotion = usePrefersReducedMotion();
const overallStartRef = useRef<number | null>(null);
const lastUpdateRef = useRef<number>(Date.now());
const currentSinceRef = useRef<number>(Date.now());
const advanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [, forceTick] = useState(0);
useEffect(() => {
if (interrupted || stage == null) {
overallStartRef.current = null;
dispatch({ type: 'RESET' });
return;
}
if (overallStartRef.current == null) {
overallStartRef.current = Date.now();
}
lastUpdateRef.current = Date.now();
dispatch({ type: 'INCOMING', item: { stage, label, detail } });
}, [stage, label, detail, interrupted]);
// Reset the per-stage clock whenever the displayed stage changes.
useEffect(() => {
currentSinceRef.current = Date.now();
}, [state.current?.stage]);
// Enforce the minimum display time per stage before advancing the queue.
useEffect(() => {
if (advanceTimerRef.current) {
clearTimeout(advanceTimerRef.current);
advanceTimerRef.current = null;
}
if (state.queue.length === 0) return undefined;
const elapsed = Date.now() - currentSinceRef.current;
const remaining = Math.max(MIN_DISPLAY_MS - elapsed, 0);
advanceTimerRef.current = setTimeout(() => {
dispatch({ type: 'ADVANCE' });
}, remaining);
return () => {
if (advanceTimerRef.current) clearTimeout(advanceTimerRef.current);
};
}, [state.queue.length]);
// Tick once a second while active so elapsed/"still working" copy stays fresh.
useEffect(() => {
if (!state.current) return undefined;
const id = setInterval(() => forceTick((n) => n + 1), 1000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [Boolean(state.current)]);
if (interrupted) return null;
const now = Date.now();
const elapsedMs = overallStartRef.current != null ? now - overallStartRef.current : 0;
const silentMs = now - lastUpdateRef.current;
const showElapsed = state.current != null && elapsedMs >= ELAPSED_THRESHOLD_MS;
const stillWorking = state.current != null && silentMs >= SILENCE_THRESHOLD_MS;
const elapsedLabel = showElapsed ? `${Math.floor(elapsedMs / 1000)}s` : null;
return (
<Root role="status" aria-live="polite">
{history.length > 0 && (
<HistoryList>
{history.map((entry, i) => (
<HistoryItem key={`${entry.stage}-${entry.startedAt}-${i}`}>
<CheckMark aria-hidden="true">✓</CheckMark>
<span>{entry.label}</span>
</HistoryItem>
))}
</HistoryList>
)}
{state.current ? (
<CurrentRow>
<Spinner
aria-hidden="true"
$reducedMotion={reducedMotion}
data-testid="activity-spinner"
data-reduced-motion={reducedMotion}
/>
<TextColumn>
<LabelText>
{stillWorking ? 'Still working…' : state.current.label || 'Working…'}
</LabelText>
{state.current.detail && (
<DetailText title={state.current.detail}>{state.current.detail}</DetailText>
)}
</TextColumn>
{elapsedLabel && !stillWorking && <MetaText>{elapsedLabel}</MetaText>}
</CurrentRow>
) : (
<DotsRow data-testid="activity-dots-fallback">
<Dot $delay="-0.32s" $reducedMotion={reducedMotion} />
<Dot $delay="-0.16s" $reducedMotion={reducedMotion} />
<Dot $delay="0s" $reducedMotion={reducedMotion} />
</DotsRow>
)}
</Root>
);
};
export default ActivityIndicator;
@@ -3,7 +3,6 @@ import { AccountContext } from '../../contexts/AccountContext';
import { AuthContext } from '../../contexts/AuthContext';
import { useAnalyticsConsent } from '../../contexts/AnalyticsConsentContext';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import { identifyHeyCatch, resetHeyCatch } from '../../utils/heycatch';
/**
* Identify on authenticated session; track Logout when session ends.
@@ -16,18 +15,7 @@ const AnalyticsSession = (): null => {
const wasAuthenticatedRef = useRef<boolean | null>(null);
useEffect(() => {
if (loading) return;
if (account) {
identifyHeyCatch(account);
} else if (wasAuthenticatedRef.current && !authenticated) {
resetHeyCatch();
}
if (!hasConsent) {
wasAuthenticatedRef.current = authenticated;
return;
}
if (!hasConsent || loading) return;
if (account) {
identifyAccount(account);
@@ -157,16 +157,16 @@ const mockFinanceGets = ({
plans?: unknown[];
} = {}) => {
mockGet.mockImplementation((url: string) => {
if (url === '/monetization/invoices/') {
if (url === '/finance/invoices/') {
return Promise.resolve({ data: invoices });
}
if (url === '/monetization/payments/') {
if (url === '/finance/payments/') {
return Promise.resolve({ data: payments });
}
if (url === '/monetization/subscription/') {
if (url === '/finance/subscription/') {
return Promise.resolve({ data: subscription });
}
if (url === '/monetization/plans/') {
if (url === '/finance/plans/') {
return Promise.resolve({ data: plans });
}
return Promise.reject(new Error(`unexpected GET ${url}`));
@@ -246,7 +246,7 @@ describe('BillingSection', () => {
await user.click(screen.getByRole('button', { name: /Change plan/i }));
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/monetization/portal/', {
expect(mockPost).toHaveBeenCalledWith('/finance/portal/', {
return_url: 'http://localhost/account/',
});
});
@@ -321,7 +321,7 @@ describe('BillingSection', () => {
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/monetization/checkout/',
'/finance/checkout/',
expect.objectContaining({
success_url: expect.stringContaining('/billing/success'),
cancel_url: expect.stringContaining('/billing/cancel'),
@@ -378,7 +378,7 @@ describe('BillingSection', () => {
expect(mockPurchasePlan).toHaveBeenCalled();
});
expect(mockPost).not.toHaveBeenCalledWith(
'/monetization/checkout/',
'/finance/checkout/',
expect.anything()
);
});
@@ -292,10 +292,10 @@ const BillingSection = (): JSX.Element => {
try {
const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] =
await Promise.all([
axiosInstance.get<FinanceInvoice[]>('/monetization/invoices/'),
axiosInstance.get<FinancePayment[]>('/monetization/payments/'),
axiosInstance.get<SubscriptionMe>('/monetization/subscription/'),
axiosInstance.get<SubscriptionPlanInfo[]>('/monetization/plans/'),
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
axiosInstance.get<FinancePayment[]>('/finance/payments/'),
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
axiosInstance.get<SubscriptionPlanInfo[]>('/finance/plans/'),
]);
setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []);
setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []);
@@ -367,9 +367,7 @@ const BillingSection = (): JSX.Element => {
date: payment.paid_at || payment.created,
description: 'Payment',
provider:
['revenuecat', 'app_store', 'play_store'].includes(
(payment.provider || '').toLowerCase()
)
(payment.provider || '').toLowerCase() === 'revenuecat'
? 'Store'
: (payment.provider || '').toLowerCase() === 'stripe'
? 'Stripe'
@@ -387,7 +385,7 @@ const BillingSection = (): JSX.Element => {
try {
const returnUrl = `${window.location.origin}/account/`;
const response = await axiosInstance.post<{ portal_url: string }>(
'/monetization/portal/',
'/finance/portal/',
{ return_url: returnUrl }
);
const portalUrl = response.data?.portal_url;
@@ -416,7 +414,9 @@ const BillingSection = (): JSX.Element => {
...(planSlug ? { plan_slug: planSlug } : {}),
});
await purchasePlan(planSlug);
setActionNotice('Purchase complete.');
setActionNotice(
'Purchase submitted. Entitlements update after the store confirms — tap Refresh shortly.'
);
await loadBilling();
} catch (error: unknown) {
if (isPurchaseCancelledError(error)) {
@@ -461,7 +461,7 @@ const BillingSection = (): JSX.Element => {
...(planSlug ? { plan_slug: planSlug } : {}),
});
const response = await axiosInstance.post<{ checkout_url: string }>(
'/monetization/checkout/',
'/finance/checkout/',
{
success_url,
cancel_url,
@@ -36,7 +36,7 @@ describe('ConversationDetailCard citations (#98)', () => {
expect(out).toContain('```\n[9]\n```');
});
it('does not render Sources list card', () => {
it('renders Sources list from citations prop', () => {
renderCard({
message: 'Answer with [1]',
user_created: false,
@@ -49,13 +49,24 @@ describe('ConversationDetailCard citations (#98)', () => {
},
],
});
expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Source 1' })).toBeInTheDocument();
expect(screen.getByLabelText('Sources')).toBeInTheDocument();
const link = screen.getByRole('link', { name: 'Example Source' });
expect(link).toHaveAttribute('href', 'https://example.com/a');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(link).toHaveAttribute('target', '_blank');
});
it('opens citation URL in a new tab when inline marker clicked', async () => {
it('renders nothing for Sources when citations empty', () => {
renderCard({
message: 'No sources here',
user_created: false,
citations: [],
});
expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument();
});
it('highlights source when inline citation clicked', async () => {
const user = userEvent.setup();
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
renderCard({
message: 'See [1]',
user_created: false,
@@ -64,11 +75,6 @@ describe('ConversationDetailCard citations (#98)', () => {
],
});
await user.click(screen.getByRole('button', { name: 'Source 1' }));
expect(openSpy).toHaveBeenCalledWith(
'https://example.com',
'_blank',
'noopener,noreferrer',
);
openSpy.mockRestore();
expect(document.getElementById('citation-source-1')).toBeInTheDocument();
});
});
@@ -116,34 +116,4 @@ describe('ConversationDetailCard', () => {
expect(agentBubble).not.toBeNull();
expect(getComputedStyle(agentBubble!).color).toBe('rgb(255, 255, 255)');
});
it('renders activity label instead of dots when stage is set (#96)', () => {
render(
<MemoryRouter>
<ThemeProvider theme={darkTheme as never}>
<ConversationDetailCard
message=""
user_created={false}
activityStage="searching"
activityLabel="Searching the web"
activityDetail="Taylor Swift wedding"
/>
</ThemeProvider>
</MemoryRouter>,
);
expect(screen.getByText('Searching the web')).toBeInTheDocument();
expect(screen.getByText('Taylor Swift wedding')).toBeInTheDocument();
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
});
it('falls back to dots when empty message has no activity stage (#96)', () => {
render(
<MemoryRouter>
<ThemeProvider theme={darkTheme as never}>
<ConversationDetailCard message="" user_created={false} />
</ThemeProvider>
</MemoryRouter>,
);
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
});
});
@@ -1,13 +1,13 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import Markdown from 'markdown-to-jsx';
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 { Citation } from '../../utils/wsFrames';
import type { PromptRating } from '../../utils/promptFeedback';
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
import SourcesList from '../SourcesList/SourcesList';
import MessageActions from '../MessageActions/MessageActions';
import ActivityIndicator from '../ActivityIndicator/ActivityIndicator';
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(10px); }
@@ -95,6 +95,30 @@ const Bubble = styled.div<{ $isUser: boolean }>`
}
`;
const LoadingDot = styled.div`
width: 8px;
height: 8px;
background: currentColor;
border-radius: 50%;
margin: 0 4px;
animation: bounce 1.4s infinite ease-in-out both;
&:nth-child(1) { animation-delay: -0.32s; }
&:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
`;
const LoadingContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
`;
const UpgradeNotice = styled.div`
display: flex;
flex-direction: column;
@@ -164,13 +188,6 @@ type ConversationDetailCardProps = {
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;
imageSrc?: string | null;
};
const MyPlot = ({ format, image }: { format: string; image: string }) => {
@@ -203,13 +220,9 @@ const ConversationDetailCard = ({
createdTimestamp = null,
initialRating = null,
onRatingChange,
activityStage = null,
activityLabel = null,
activityDetail = null,
activityHistory = [],
activityInterrupted = false,
imageSrc = null,
}: ConversationDetailCardProps): JSX.Element => {
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
const CitationMark = useCallback(
({ indices }: { indices?: string }) => {
const list = (indices || '')
@@ -228,9 +241,12 @@ const ConversationDetailCard = ({
aria-label={`Source ${index}`}
title={citation?.title || `Source ${index}`}
onClick={() => {
setHighlightIndex(index);
if (citation?.url) {
window.open(citation.url, '_blank', 'noopener,noreferrer');
// Prefer scroll/highlight; URL still available from Sources list
}
const el = document.getElementById(`citation-source-${index}`);
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
}}
>
[{index}]
@@ -253,13 +269,11 @@ const ConversationDetailCard = ({
return (
<MessageContainer $isUser={false}>
<Bubble $isUser={false}>
<ActivityIndicator
stage={activityStage}
label={activityLabel}
detail={activityDetail}
history={activityHistory}
interrupted={activityInterrupted}
/>
<LoadingContainer>
<LoadingDot />
<LoadingDot />
<LoadingDot />
</LoadingContainer>
</Bubble>
</MessageContainer>
);
@@ -318,19 +332,6 @@ const ConversationDetailCard = ({
return (
<MessageContainer $isUser={user_created}>
<Bubble $isUser={user_created}>
{imageSrc && (
<img
src={imageSrc}
alt="Attachment"
style={{
display: 'block',
maxWidth: '100%',
maxHeight: 240,
borderRadius: 8,
marginBottom: '0.75rem',
}}
/>
)}
<Markdown
className="display-linebreak"
style={{ whiteSpace: 'pre-line' }}
@@ -355,6 +356,10 @@ const ConversationDetailCard = ({
</Markdown>
</Bubble>
{!user_created && citations.length > 0 && (
<SourcesList citations={citations} highlightIndex={highlightIndex} />
)}
<MessageActions
promptId={promptId}
rawMarkdown={rawForCopy}
@@ -25,8 +25,5 @@ describe('Footer', () => {
'href',
'/terms_of_service/',
);
expect(screen.getByRole('link', { name: 'Pricing' })).toHaveAttribute('href', '/pricing/');
expect(screen.getByRole('link', { name: 'Privacy' })).toHaveAttribute('href', '/privacy/');
expect(screen.getByRole('link', { name: 'Changelog' })).toHaveAttribute('href', '/changelog/');
});
});
@@ -1,7 +1,6 @@
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import MDTypography from '../../ui-kit/components/MDTypography';
import { PUBLIC_FOOTER_LINKS } from '../PublicSite/publicNav';
const Footer = (): JSX.Element => {
return (
@@ -9,12 +8,8 @@ const Footer = (): JSX.Element => {
<MDTypography>
&copy; 2026 Hesychia | Developed by{' '}
<a href="https://www.aimloperations.com">AI ML Operations, LLC</a>
{PUBLIC_FOOTER_LINKS.map((link) => (
<React.Fragment key={link.to}>
{' · '}
<RouterLink to={link.to}>{link.label}</RouterLink>
</React.Fragment>
))}
{' · '}
<RouterLink to="/terms_of_service/">Terms of Service</RouterLink>
</MDTypography>
</div>
);
@@ -86,7 +86,7 @@ describe('Header2 (#81 subscription-aware Documents nav link)', () => {
renderHeader();
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/monetization/subscription/'));
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/finance/subscription/'));
expect(screen.queryByText('Documents')).not.toBeInTheDocument();
});
@@ -1,47 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import HomeRoute from './HomeRoute';
import { AuthContext } from '../../contexts/AuthContext';
jest.mock('../../pages/AsyncDashboard2/AsyncDashboard2', () => () => <div>Chat app</div>);
jest.mock('../../pages/Landing/LandingPage', () => () => <h1>Hesychia is a calm AI assistant.</h1>);
const renderHome = (authenticated: boolean, loading: boolean) => {
render(
<MemoryRouter>
<AuthContext.Provider
value={{
authenticated,
setAuthentication: () => {},
needsNewPassword: false,
setNeedsNewPassword: () => {},
loading,
}}
>
<HomeRoute />
</AuthContext.Provider>
</MemoryRouter>,
);
};
describe('HomeRoute', () => {
it('shows a loading state before auth is known', () => {
renderHome(false, true);
expect(screen.getByText('Loading')).toBeInTheDocument();
expect(screen.queryByText('Chat app')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /calm AI assistant/i })).not.toBeInTheDocument();
});
it('shows the public landing page when signed out', () => {
renderHome(false, false);
expect(screen.getByRole('heading', { name: /calm AI assistant/i })).toBeInTheDocument();
expect(screen.queryByText('Chat app')).not.toBeInTheDocument();
});
it('keeps the chat app on / for a signed-in user', () => {
renderHome(true, false);
expect(screen.getByText('Chat app')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /calm AI assistant/i })).not.toBeInTheDocument();
});
});
@@ -1,21 +0,0 @@
import { useContext } from 'react';
import { AuthContext } from '../../contexts/AuthContext';
import AsyncDashboard2 from '../../pages/AsyncDashboard2/AsyncDashboard2';
import LandingPage from '../../pages/Landing/LandingPage';
/** Logged-out `/` is the public site. Logged-in `/` stays the chat. */
const HomeRoute = (): JSX.Element => {
const { authenticated, loading } = useContext(AuthContext);
if (loading) {
return <div>Loading</div>;
}
if (authenticated) {
return <AsyncDashboard2 />;
}
return <LandingPage />;
};
export default HomeRoute;
@@ -309,6 +309,7 @@ const MessageActions = ({
{(
[
['pdf', 'PDF'],
['docx', 'Word (.docx)'],
['csv', 'CSV'],
['xlsx', 'Excel (.xlsx)'],
['txt', 'Plain text'],
@@ -1,222 +0,0 @@
import React, { FormEvent, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
import { publicAccent, publicCard, publicLine, publicText } from './publicColors';
const Stack = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
width: 100%;
max-width: 28rem;
`;
const Row = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
`;
const Primary = styled(Link)`
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
text-decoration: none;
border-radius: 0.5rem;
padding: 0.9rem 1.25rem;
font-weight: 600;
font-size: 1rem;
`;
const Secondary = styled(Link)`
color: ${publicAccent};
font-weight: 600;
`;
const Quiet = styled(Link)`
color: ${publicText};
opacity: 0.75;
font-size: 0.95rem;
`;
const Form = styled.form`
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
width: 100%;
`;
const Label = styled.label`
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
`;
const Input = styled.input`
flex: 1 1 14rem;
background: ${publicCard};
border: 1px solid ${publicLine};
border-radius: 0.5rem;
padding: 0.9rem 1rem;
color: ${publicText};
font-size: 1rem;
`;
const Submit = styled.button`
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 0.5rem;
color: #fff;
padding: 0.9rem 1.25rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: default;
}
`;
const Note = styled.p`
margin: 0;
color: ${publicText};
font-size: 0.95rem;
`;
type GetStartedProps = {
source: 'landing' | 'pricing';
idPrefix?: string;
showPricingLink?: boolean;
};
/**
* Create account when registration is on. Email waitlist when it is off or unknown.
* Settings default to off in the API, so a failed lookup stays on the waitlist.
*/
const GetStarted = ({
source,
idPrefix = 'waitlist',
showPricingLink = true,
}: GetStartedProps): JSX.Element => {
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
const [email, setEmail] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
axiosInstance
.get('/public/settings/')
.then((response) => {
if (cancelled) return;
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
})
.catch(() => {
if (!cancelled) setRegistrationEnabled(false);
});
return () => {
cancelled = true;
};
}, []);
const onCreateAccount = () => {
trackEvent(AnalyticsEvents.LANDING_PRIMARY_CTA, { source, action: 'create_account' });
};
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = email.trim();
if (!trimmed) {
setError('Enter an email address.');
setMessage(null);
return;
}
setSubmitting(true);
setError(null);
setMessage(null);
try {
await axiosInstance.post('/public/waitlist/', { email: trimmed });
trackEvent(AnalyticsEvents.WAITLIST_JOINED, { source });
setMessage("You're on the list.");
setEmail('');
} catch {
setError('Could not join the waitlist. Try again.');
} finally {
setSubmitting(false);
}
};
if (registrationEnabled === null) {
return <Note>Checking signup…</Note>;
}
if (registrationEnabled) {
return (
<Stack>
<Row>
<Primary to="/signup/" onClick={onCreateAccount}>
Create account
</Primary>
{showPricingLink && (
<Secondary
to="/pricing/"
onClick={() => trackEvent(AnalyticsEvents.LANDING_PRICING_CTA, { source })}
>
See pricing
</Secondary>
)}
</Row>
<Quiet to="/signin/">Sign in</Quiet>
</Stack>
);
}
return (
<Stack>
<Form onSubmit={onSubmit}>
<Label htmlFor={`${idPrefix}-email`}>Email</Label>
<Input
id={`${idPrefix}-email`}
name="email"
type="email"
autoComplete="email"
placeholder="Email address"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<Submit type="submit" disabled={submitting}>
{submitting ? 'Joining…' : 'Join the waitlist'}
</Submit>
</Form>
{message && <Note role="status">{message}</Note>}
{error && <Note role="alert">{error}</Note>}
<Row>
{showPricingLink && (
<Secondary
to="/pricing/"
onClick={() => trackEvent(AnalyticsEvents.LANDING_PRICING_CTA, { source })}
>
See pricing
</Secondary>
)}
<Quiet to="/signin/">Sign in</Quiet>
</Row>
</Stack>
);
};
export default GetStarted;
@@ -1,136 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import ParticleBackground from '../ParticleBackground/ParticleBackground';
import RelatedPages from './RelatedPages';
import { PUBLIC_FOOTER_LINKS, PUBLIC_HEADER_LINKS } from './publicNav';
const text = ({ theme }: { theme?: { colors?: { text?: string } } }) =>
theme?.colors?.text ?? '#344767';
const line = ({ theme }: { theme?: { colors?: { cardBorder?: string } } }) =>
theme?.colors?.cardBorder ?? 'rgba(0, 0, 0, 0.08)';
const Page = styled.div`
position: relative;
z-index: 1;
min-height: 100vh;
background: transparent;
color: ${text};
font-family: Inter, sans-serif;
display: flex;
flex-direction: column;
`;
const Header = styled.header`
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid ${line};
`;
const Brand = styled(Link)`
color: ${text};
font-weight: 700;
font-size: 1.15rem;
text-decoration: none;
letter-spacing: 0.02em;
`;
const Nav = styled.nav`
display: flex;
flex-wrap: wrap;
gap: 1rem 1.25rem;
align-items: center;
`;
const NavLink = styled(Link)`
color: ${text};
text-decoration: none;
font-size: 0.95rem;
opacity: 0.85;
&:hover {
opacity: 1;
}
`;
const SignInLink = styled(Link)`
color: ${text};
text-decoration: none;
border: 1px solid ${line};
border-radius: 0.5rem;
padding: 0.45rem 0.8rem;
font-size: 0.95rem;
`;
const Main = styled.main`
width: min(880px, 100%);
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
flex: 1;
`;
const FooterBar = styled.footer`
border-top: 1px solid ${line};
padding: 1.5rem;
color: ${text};
font-size: 0.9rem;
opacity: 0.85;
`;
const FooterNav = styled.nav`
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
margin-bottom: 0.75rem;
`;
const FooterLink = styled(Link)`
color: ${text};
`;
type PublicSiteLayoutProps = {
children: React.ReactNode;
};
const PublicSiteLayout = ({ children }: PublicSiteLayoutProps): JSX.Element => {
return (
<Page>
<ParticleBackground />
<Header>
<Brand to="/">Hesychia</Brand>
<Nav aria-label="Primary">
{PUBLIC_HEADER_LINKS.map((link) => (
<NavLink key={link.to} to={link.to}>
{link.label}
</NavLink>
))}
<SignInLink to="/signin/">Sign in</SignInLink>
</Nav>
</Header>
<Main>
{children}
<RelatedPages />
</Main>
<FooterBar>
<FooterNav aria-label="Footer">
{PUBLIC_FOOTER_LINKS.map((link) => (
<FooterLink key={link.to} to={link.to}>
{link.label}
</FooterLink>
))}
</FooterNav>
<div>
© 2026 Hesychia · Developed by{' '}
<a href="https://www.aimloperations.com">AI ML Operations, LLC</a>
</div>
</FooterBar>
</Page>
);
};
export default PublicSiteLayout;
@@ -1,36 +0,0 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { linksForPath } from '../../utils/internalLinks';
import { publicAccent } from './publicColors';
const Nav = styled.nav`
margin-top: 2.5rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.45rem;
`;
const Item = styled(Link)`
color: ${publicAccent};
`;
/** Same neighbours the static shell exposes to crawlers that do not run JavaScript. */
const RelatedPages = (): JSX.Element | null => {
const { pathname } = useLocation();
const links = linksForPath(pathname);
if (links.length < 2) return null;
return (
<Nav aria-label="Related pages">
{links.map((link) => (
<Item key={link.href} to={link.href}>
{link.label}
</Item>
))}
</Nav>
);
};
export default RelatedPages;
@@ -1,84 +0,0 @@
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import { formatPublicCount, shouldShowPublicUsage } from './publicCount';
import { publicText } from './publicColors';
const Section = styled.section`
margin-top: 1.5rem;
color: ${publicText};
font-size: 0.95rem;
`;
const Figures = styled.p`
margin: 0 0 0.35rem;
font-weight: 600;
`;
const AsOf = styled.p`
margin: 0;
color: ${publicText};
opacity: 0.7;
`;
type PublicUsage = {
account_count: number;
document_count: number;
as_of: string;
};
/**
* Live counts only. Hidden when the API fails, or until 20 accounts exist, so the page
* never shows a made-up number. Once shown, both figures are rounded down and marked with +.
* Avatars are omitted: no one has consented to publish a photo.
*/
const UsageCounts = (): JSX.Element | null => {
const [usage, setUsage] = useState<PublicUsage | null>(null);
useEffect(() => {
let cancelled = false;
axiosInstance
.get('/public/usage/')
.then((response) => {
if (cancelled) return;
const data = response.data;
if (
typeof data?.account_count !== 'number' ||
typeof data?.document_count !== 'number' ||
typeof data?.as_of !== 'string'
) {
return;
}
setUsage({
account_count: data.account_count,
document_count: data.document_count,
as_of: data.as_of,
});
})
.catch(() => {
/* leave hidden */
});
return () => {
cancelled = true;
};
}, []);
if (!usage || !shouldShowPublicUsage(usage.account_count)) return null;
const asOfDate = usage.as_of.slice(0, 10);
return (
<Section aria-label="Usage">
<Figures>
{formatPublicCount(usage.account_count, 'accounts')}
{' · '}
{formatPublicCount(usage.document_count, 'documents stored')}
</Figures>
<AsOf>
As of <time dateTime={usage.as_of}>{asOfDate}</time>
</AsOf>
</Section>
);
};
export default UsageCounts;
@@ -1,17 +0,0 @@
type PublicTheme = {
colors?: {
text?: string;
cardBackground?: string;
cardBorder?: string;
};
};
export const publicText = ({ theme }: { theme?: PublicTheme }) => theme?.colors?.text ?? '#344767';
export const publicCard = ({ theme }: { theme?: PublicTheme }) =>
theme?.colors?.cardBackground ?? 'rgba(255, 255, 255, 0.72)';
export const publicLine = ({ theme }: { theme?: PublicTheme }) =>
theme?.colors?.cardBorder ?? 'rgba(0, 0, 0, 0.08)';
export const publicAccent = '#667eea';
@@ -1,28 +0,0 @@
import { formatPublicCount, roundedPublicCount, shouldShowPublicUsage } from './publicCount';
describe('public usage counts', () => {
it('stays hidden until 20 accounts', () => {
expect(shouldShowPublicUsage(0)).toBe(false);
expect(shouldShowPublicUsage(19)).toBe(false);
expect(shouldShowPublicUsage(19.5)).toBe(false);
expect(shouldShowPublicUsage(20)).toBe(true);
expect(shouldShowPublicUsage(156)).toBe(true);
});
it('rounds down to one significant digit and marks the bucket with +', () => {
expect(roundedPublicCount(9)).toBeNull();
expect(roundedPublicCount(10)).toBe(10);
expect(roundedPublicCount(23)).toBe(20);
expect(roundedPublicCount(99)).toBe(90);
expect(roundedPublicCount(100)).toBe(100);
expect(roundedPublicCount(156)).toBe(100);
expect(roundedPublicCount(999)).toBe(900);
expect(roundedPublicCount(1000)).toBe(1000);
expect(roundedPublicCount(2500)).toBe(2000);
expect(formatPublicCount(4, 'documents stored')).toBe('under 10 documents stored');
expect(formatPublicCount(23, 'accounts')).toBe('20+ accounts');
expect(formatPublicCount(156, 'documents stored')).toBe('100+ documents stored');
expect(formatPublicCount(2500, 'accounts')).toBe('2,000+ accounts');
});
});
@@ -1,34 +0,0 @@
/** Hide live counts until this many accounts exist. Below that, the figures are noise. */
export const MIN_PUBLIC_ACCOUNTS = 20;
export function shouldShowPublicUsage(accountCount: number): boolean {
return Number.isInteger(accountCount) && accountCount >= MIN_PUBLIC_ACCOUNTS;
}
/** Largest power of ten that does not exceed count. 23 → 10, 156 → 100, 2500 → 1000. */
function stepFor(count: number): number {
let step = 1;
while (step * 10 <= count) step *= 10;
return step;
}
/**
* Floor to one significant digit so the public figure never exceeds the real count.
* Counts under 10 have no honest "+" bucket.
*/
export function roundedPublicCount(count: number): number | null {
if (!Number.isInteger(count) || count < 10) return null;
const step = stepFor(count);
return Math.floor(count / step) * step;
}
function grouped(value: number): string {
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/** "20+ accounts" or "under 10 documents stored". Noun is the already-plural label. */
export function formatPublicCount(count: number, noun: string): string {
const rounded = roundedPublicCount(count);
if (rounded === null) return `under 10 ${noun}`;
return `${grouped(rounded)}+ ${noun}`;
}
@@ -1,18 +0,0 @@
/** Shared public links. Header stays short; footer adds legal pages. No About until a founder is named. */
export type PublicNavLink = {
to: string;
label: string;
};
export const PUBLIC_HEADER_LINKS: readonly PublicNavLink[] = [
{ to: '/#product', label: 'Product' },
{ to: '/pricing/', label: 'Pricing' },
{ to: '/changelog/', label: 'Changelog' },
];
export const PUBLIC_FOOTER_LINKS: readonly PublicNavLink[] = [
...PUBLIC_HEADER_LINKS,
{ to: '/privacy/', label: 'Privacy' },
{ to: '/terms_of_service/', label: 'Terms of Service' },
];
@@ -1,19 +0,0 @@
import { useContext, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { AuthContext } from '../../contexts/AuthContext';
import { applyRouteMeta, metaForPath } from '../../utils/routeMeta';
/** Sets document title and canonical from the current route. */
const RouteMeta = (): null => {
const { pathname } = useLocation();
const { authenticated, loading } = useContext(AuthContext);
useEffect(() => {
if (loading) return;
applyRouteMeta(metaForPath(pathname, authenticated));
}, [pathname, authenticated, loading]);
return null;
};
export default RouteMeta;
@@ -0,0 +1,138 @@
import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import type { Citation } from '../../utils/wsFrames';
const SourcesRoot = styled.aside`
margin-top: 0.65rem;
max-width: 80%;
min-width: 0;
width: 100%;
padding: 0.65rem 0.85rem;
border-radius: 0.75rem;
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
background: ${({ theme }) =>
theme.darkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)'};
color: ${({ theme }) => theme.colors.text};
@media (max-width: 768px) {
max-width: 92%;
}
`;
const SourcesTitle = styled.h4`
margin: 0 0 0.5rem;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
opacity: 0.75;
`;
const SourceList = styled.ol`
margin: 0;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
`;
const SourceItem = styled.li<{ $highlight: boolean }>`
font-size: 0.85rem;
line-height: 1.4;
border-radius: 0.35rem;
padding: 0.15rem 0.25rem;
outline: ${({ $highlight, theme }) =>
$highlight ? `2px solid ${theme.main}` : 'none'};
background: ${({ $highlight, theme }) =>
$highlight
? theme.darkMode
? 'rgba(255,255,255,0.08)'
: 'rgba(0,0,0,0.06)'
: 'transparent'};
transition: background 0.2s ease, outline 0.2s ease;
`;
const SourceLink = styled.a`
color: ${({ theme }) => (theme.darkMode ? '#a0c4ff' : theme.main)};
text-decoration: underline;
word-break: break-word;
`;
const SourceMeta = styled.span`
display: block;
font-size: 0.75rem;
opacity: 0.65;
margin-top: 0.1rem;
`;
function domainFromUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
type SourcesListProps = {
citations: Citation[];
highlightIndex?: number | null;
};
const SourcesList = ({
citations,
highlightIndex = null,
}: SourcesListProps): JSX.Element | null => {
const itemRefs = useRef<Record<number, HTMLLIElement | null>>({});
const [active, setActive] = useState<number | null>(highlightIndex);
useEffect(() => {
setActive(highlightIndex ?? null);
if (highlightIndex == null) return;
const el = itemRefs.current[highlightIndex];
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
}, [highlightIndex]);
if (!citations.length) return null;
const sorted = [...citations].sort((a, b) => a.index - b.index);
return (
<SourcesRoot aria-label="Sources">
<SourcesTitle>Sources</SourcesTitle>
<SourceList>
{sorted.map((citation) => {
const domain = domainFromUrl(citation.url);
return (
<SourceItem
key={citation.index}
id={`citation-source-${citation.index}`}
$highlight={active === citation.index}
ref={(node) => {
itemRefs.current[citation.index] = node;
}}
>
{citation.url ? (
<SourceLink
href={citation.url}
target="_blank"
rel="noopener noreferrer"
>
{citation.title || `Source ${citation.index}`}
</SourceLink>
) : (
<span>{citation.title || `Source ${citation.index}`}</span>
)}
{(citation.published_at || domain) && (
<SourceMeta>
{[citation.published_at, domain].filter(Boolean).join(' · ')}
</SourceMeta>
)}
</SourceItem>
);
})}
</SourceList>
</SourcesRoot>
);
};
export default SourcesList;
@@ -1,7 +1,5 @@
import React, { useContext, useEffect, useState } from 'react';
import styled from 'styled-components';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlined from '@mui/icons-material/InfoOutlined';
import {
useMaterialUIController,
setDarkMode,
@@ -50,16 +48,6 @@ const SettingLabel = styled.span`
font-size: 1.1rem;
color: ${({ theme }) => theme.colors.text};
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`
@@ -105,11 +93,6 @@ const Checkbox = styled.input`
&:checked + ${Slider}:before {
transform: translateX(26px);
}
&:disabled + ${Slider} {
opacity: 0.5;
cursor: not-allowed;
}
`;
const ColorPickerContainer = styled.div`
@@ -132,23 +115,6 @@ const ColorButton = styled.button<{ color: string; isSelected: boolean }>`
}
`;
const ErrorText = styled.p`
color: #ff6b6b;
margin: 0.75rem 0 0 0;
font-size: 0.95rem;
`;
const CONTEXT_TOOLTIP =
'Follow-ups use earlier messages in this chat. Turn off to answer each message on its own.';
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 = {
order: boolean;
}
@@ -162,10 +128,6 @@ const ThemeSettingsCard = (): JSX.Element => {
const themeColors = Object.keys(palettes);
const [order, setOrder] = useState<boolean>(true);
const [useConversationContext, setUseConversationContext] = useState<boolean>(true);
const [prefsLoading, setPrefsLoading] = useState<boolean>(true);
const [contextSaving, setContextSaving] = useState<boolean>(false);
const [prefsError, setPrefsError] = useState<string>('');
const { updatePreferences } = useContext(PreferenceContext);
const handleConversationOrder = async ({ order }: PreferencesValues): Promise<void> => {
@@ -179,50 +141,17 @@ const ThemeSettingsCard = (): JSX.Element => {
}
}
async function getConversationPreferences() {
setPrefsLoading(true);
setPrefsError('');
async function getConversationOrder() {
try {
const { data }: AxiosResponse<PreferencesType> = await axiosInstance.get(
`/conversation_preferences`
);
const { data, }: AxiosResponse<PreferencesType> = await axiosInstance.get(`/conversation_preferences`);
setOrder(data.order);
setUseConversationContext(Boolean(data.use_conversation_context));
} catch (error) {
setPrefsError(
prefsErrorMessage(error, 'Could not load account preferences.')
);
} finally {
setPrefsLoading(false);
console.log(error)
}
}
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(() => {
getConversationPreferences();
getConversationOrder();
}, [])
const handleDarkMode = () => setDarkMode(dispatch, !darkMode);
@@ -264,7 +193,6 @@ const ThemeSettingsCard = (): JSX.Element => {
<Checkbox
type="checkbox"
checked={order}
disabled={prefsLoading}
onChange={() => {
setOrder(!order);
handleConversationOrder({ order: !order })
@@ -274,30 +202,6 @@ const ThemeSettingsCard = (): JSX.Element => {
</ToggleSwitch>
</SettingRow>
<SettingRow>
<SettingLabel>
Remember this chat
<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>
);
};
@@ -1,25 +1,14 @@
import React from 'react';
import { Capacitor } from '@capacitor/core';
import { render } from '@testing-library/react';
import Tracker from './Tracker';
const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
const GTAG_SCRIPT_ID = 'hesychia-gtag';
const GTAG_SRC = 'https://www.googletagmanager.com/gtag/js?id=G-V12DLRLPWR';
jest.mock('@capacitor/core', () => ({
Capacitor: {
getPlatform: jest.fn(() => 'web'),
},
}));
describe('Tracker', () => {
const originalEnv = process.env;
beforeEach(() => {
(Capacitor.getPlatform as jest.Mock).mockReturnValue('web');
document.body.innerHTML = '';
document.head.innerHTML = '';
process.env = {
...originalEnv,
REACT_APP_DEPLOY_ENV: 'production',
@@ -39,11 +28,6 @@ describe('Tracker', () => {
expect(script).not.toBeNull();
expect(script?.src).toBe('https://tianji.aimloperations.com/tracker.js');
expect(script?.getAttribute('data-website-id')).toBe('cm7x7m52m03kbddswbswrt17y');
const gtag = document.getElementById(GTAG_SCRIPT_ID) as HTMLScriptElement | null;
expect(gtag?.src).toBe(GTAG_SRC);
expect(gtag?.async).toBe(true);
expect(window.gtag).toEqual(expect.any(Function));
});
it('loads distinct website id for beta builds', () => {
@@ -57,21 +41,6 @@ describe('Tracker', () => {
const script = document.getElementById(TRACKER_SCRIPT_ID) as HTMLScriptElement | null;
expect(script?.getAttribute('data-website-id')).toBe('beta-website-id-test');
expect(document.getElementById(GTAG_SCRIPT_ID)).toBeNull();
});
it('loads Google tag on the iOS app even when the web env is beta', () => {
(Capacitor.getPlatform as jest.Mock).mockReturnValue('ios');
process.env = {
...originalEnv,
REACT_APP_DEPLOY_ENV: 'beta',
REACT_APP_TIANJI_WEBSITE_ID: 'beta-website-id-test',
};
render(<Tracker />);
const gtag = document.getElementById(GTAG_SCRIPT_ID) as HTMLScriptElement | null;
expect(gtag?.src).toBe(GTAG_SRC);
});
it('does not load Tianji tracker outside analytics environments', () => {
@@ -85,6 +54,5 @@ describe('Tracker', () => {
render(<Tracker />);
expect(document.getElementById(TRACKER_SCRIPT_ID)).toBeNull();
expect(document.getElementById(GTAG_SCRIPT_ID)).toBeNull();
});
});
@@ -1,28 +1,8 @@
import React, { useEffect } from 'react';
import { flushAnalyticsQueue } from '../../utils/analytics';
import { isAnalyticsEnvironment } from '../../utils/analyticsConsent';
import { isProductAnalyticsEnabled } from '../../utils/productAnalytics';
const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
const GTAG_SCRIPT_ID = 'hesychia-gtag';
const GTAG_ID = 'G-V12DLRLPWR';
function loadGoogleTag(): void {
if (document.getElementById(GTAG_SCRIPT_ID)) return;
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag(...args: unknown[]) {
window.dataLayer?.push(args);
};
window.gtag('js', new Date());
window.gtag('config', GTAG_ID);
const script = document.createElement('script');
script.id = GTAG_SCRIPT_ID;
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${GTAG_ID}`;
document.head.appendChild(script);
}
/**
* Loads Tianji tracker.js for automatic page views on prod/beta builds.
@@ -30,35 +10,29 @@ function loadGoogleTag(): void {
*/
const Tracker: React.FC = () => {
useEffect(() => {
if (isProductAnalyticsEnabled()) {
loadGoogleTag();
}
if (!isAnalyticsEnvironment()) return;
const websiteId = process.env.REACT_APP_TIANJI_WEBSITE_ID || '';
if (isAnalyticsEnvironment() && websiteId && !document.getElementById(TRACKER_SCRIPT_ID)) {
const script = document.createElement('script');
script.id = TRACKER_SCRIPT_ID;
script.src = 'https://tianji.aimloperations.com/tracker.js';
script.async = true;
script.defer = true;
script.setAttribute('data-website-id', websiteId);
script.setAttribute('data-do-not-track', 'true');
script.onload = () => {
flushAnalyticsQueue();
};
if (!websiteId || document.getElementById(TRACKER_SCRIPT_ID)) return;
document.body.appendChild(script);
}
const script = document.createElement('script');
script.id = TRACKER_SCRIPT_ID;
script.src = 'https://tianji.aimloperations.com/tracker.js';
script.async = true;
script.defer = true;
script.setAttribute('data-website-id', websiteId);
script.setAttribute('data-do-not-track', 'true');
script.onload = () => {
flushAnalyticsQueue();
};
document.body.appendChild(script);
return () => {
const loaded = document.getElementById(TRACKER_SCRIPT_ID);
if (loaded?.parentNode) {
loaded.parentNode.removeChild(loaded);
}
const gtag = document.getElementById(GTAG_SCRIPT_ID);
if (gtag?.parentNode) {
gtag.parentNode.removeChild(gtag);
}
};
}, []);
@@ -174,8 +174,8 @@ const UsageSummaryCard = (): JSX.Element => {
setLoading(true);
try {
const [subscriptionResponse, invoiceResponse] = await Promise.all([
axiosInstance.get<SubscriptionMe>('/monetization/subscription/'),
axiosInstance.get<FinanceInvoice[]>('/monetization/invoices/'),
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
]);
setSubscription(subscriptionResponse.data || null);
setHasPortalAccess(
@@ -231,7 +231,7 @@ const UsageSummaryCard = (): JSX.Element => {
try {
const returnUrl = `${window.location.origin}/account/`;
const response = await axiosInstance.post<{ portal_url: string }>(
'/monetization/portal/',
'/finance/portal/',
{ return_url: returnUrl }
);
if (response.data?.portal_url) {
@@ -1,128 +0,0 @@
import React, { useContext } from 'react';
import { act, render, screen } from '@testing-library/react';
import { MessageContext, MessageProvider } from './MessageContext';
import { WebSocketContext } from './WebSocketContext';
import { AccountContext } from './AccountContext';
import { ConversationContext } from './ConversationContext';
import { ConversationPrompt } from '../data';
jest.mock('../../axiosApi', () => ({
axiosInstance: {
get: jest.fn().mockResolvedValue({ data: [] }),
},
}));
jest.mock('../utils/analytics', () => ({
AnalyticsEvents: {
CONVERSATION_CREATED: 'Conversation Created',
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
},
trackEvent: jest.fn(),
}));
const OrderProbe = () => {
const ctx = useContext(MessageContext);
return (
<div>
<span data-testid="order">
{ctx.conversationDetails.map((detail) => detail.message).join('|')}
</span>
<button
type="button"
onClick={() =>
ctx.setConversationDetails([
new ConversationPrompt({
id: 4,
message: 'I dont know',
user_created: true,
created_timestamp: new Date('2020-01-01T00:03:00Z'),
}),
new ConversationPrompt({
id: 2,
message: 'Tell me a joke',
user_created: true,
created_timestamp: new Date('2020-01-01T00:01:00Z'),
}),
new ConversationPrompt({
message: '',
user_created: false,
created_timestamp: new Date('2020-01-01T00:04:00Z'),
}),
new ConversationPrompt({
id: 3,
message: 'meditation ladder',
user_created: false,
created_timestamp: new Date('2020-01-01T00:02:00Z'),
}),
])
}
>
seed
</button>
</div>
);
};
describe('MessageContext turn order (#136)', () => {
it('sorts a scrambled transcript and keeps that order when the reply finishes', async () => {
let callback: ((message: string) => void) | null = null;
render(
<AccountContext.Provider
value={{ account: { email: 'test@example.com' } as never, setAccount: () => {} }}
>
<ConversationContext.Provider
value={{
conversations: [],
setConversations: () => {},
selectedConversation: 7,
setSelectedConversation: () => {},
deleteConversation: () => {},
}}
>
<WebSocketContext.Provider
value={
[
(_channel: string, cb: (message: string) => void) => {
callback = cb;
},
jest.fn(),
null,
jest.fn(),
true,
'CONNECTED',
0,
] as never
}
>
<MessageProvider>
<OrderProbe />
</MessageProvider>
</WebSocketContext.Provider>
</ConversationContext.Provider>
</AccountContext.Provider>,
);
await act(async () => {});
act(() => {
screen.getByRole('button', { name: 'seed' }).click();
});
expect(screen.getByTestId('order')).toHaveTextContent(
'Tell me a joke|meditation ladder|I dont know|',
);
const send = (message: string) => {
act(() => {
callback?.(message);
});
};
send('CONVERSATION_ID');
send('7');
send('START_OF_THE_STREAM_ENDER_GAME_42');
send('To help everyone step');
send('END_OF_THE_STREAM_ENDER_GAME_42');
expect(screen.getByTestId('order').textContent).toBe(
'Tell me a joke|meditation ladder|I dont know|To help everyone step',
);
});
});
@@ -1,204 +0,0 @@
import React, { useContext } from 'react';
import { act, render, screen } from '@testing-library/react';
import { MessageContext, MessageProvider } from './MessageContext';
import { WebSocketContext } from './WebSocketContext';
import { AccountContext } from './AccountContext';
import { ConversationContext } from './ConversationContext';
import { trackEvent } from '../utils/analytics';
jest.mock('../../axiosApi', () => ({
axiosInstance: {
get: jest.fn().mockResolvedValue({ data: [] }),
},
}));
jest.mock('../utils/analytics', () => ({
AnalyticsEvents: {
CONVERSATION_CREATED: 'Conversation Created',
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
},
trackEvent: jest.fn(),
}));
type Capture = { callback: ((message: string) => void) | null };
const buildHarness = () => {
const capture: Capture = { callback: null };
const subscribe = (_channel: string, cb: (message: string) => void) => {
capture.callback = cb;
};
const unsubscribe = jest.fn();
const Harness = ({ reconnectGeneration = 0 }: { reconnectGeneration?: number }) => (
<AccountContext.Provider
value={{ account: { email: 'test@example.com' } as never, setAccount: () => {} }}
>
<ConversationContext.Provider
value={{
conversations: [],
setConversations: () => {},
selectedConversation: 1,
setSelectedConversation: () => {},
deleteConversation: () => {},
}}
>
<WebSocketContext.Provider
value={[subscribe, unsubscribe, null, jest.fn(), true, 'CONNECTED', reconnectGeneration] as never}
>
<MessageProvider>
<Probe />
</MessageProvider>
</WebSocketContext.Provider>
</ConversationContext.Provider>
</AccountContext.Provider>
);
return { Harness, capture };
};
const Probe = () => {
const ctx = useContext(MessageContext);
return (
<div>
<span data-testid="stage">{ctx.activityStage ?? 'null'}</span>
<span data-testid="label">{ctx.activityLabel ?? 'null'}</span>
<span data-testid="detail">{ctx.activityDetail ?? 'null'}</span>
<span data-testid="history-count">{ctx.activityHistory.length}</span>
<span data-testid="stream-interrupted">{ctx.streamInterrupted ? 'yes' : 'no'}</span>
<button type="button" onClick={() => ctx.clearActivity()}>
clear
</button>
</div>
);
};
const send = (capture: Capture, message: string) => {
act(() => {
capture.callback?.(message);
});
};
const sendStatus = (
capture: Capture,
stage: string,
label: string,
detail: string | null = null,
) => {
send(
capture,
JSON.stringify({ v: 1, type: 'status', data: { stage, label, detail } }),
);
};
describe('MessageContext activity status (#96)', () => {
beforeEach(() => {
(trackEvent as jest.Mock).mockClear();
});
it('applies the first status frame as the current stage/label/detail', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web', 'query: cats');
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
expect(screen.getByTestId('label')).toHaveTextContent('Searching the web');
expect(screen.getByTestId('detail')).toHaveTextContent('query: cats');
expect(screen.getByTestId('history-count')).toHaveTextContent('0');
});
it('rolls a completed stage into history and tracks ACTIVITY_STAGE_COMPLETED (no label/detail text)', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
sendStatus(capture, 'reading', 'Reading sources');
expect(screen.getByTestId('stage')).toHaveTextContent('reading');
expect(screen.getByTestId('label')).toHaveTextContent('Reading sources');
expect(screen.getByTestId('history-count')).toHaveTextContent('1');
expect(trackEvent).toHaveBeenCalledWith(
'Activity Stage Completed',
expect.objectContaining({ stage: 'searching', durationMs: expect.any(Number) }),
);
const call = (trackEvent as jest.Mock).mock.calls.find(
([name]) => name === 'Activity Stage Completed',
);
expect(Object.keys(call![1])).toEqual(['stage', 'durationMs']);
});
it('clears the label display (but not the stage) once the writing stage starts', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
sendStatus(capture, 'writing', 'Writing the answer');
expect(screen.getByTestId('stage')).toHaveTextContent('writing');
expect(screen.getByTestId('label')).toHaveTextContent('null');
});
it('clears activity on END_OF_THE_STREAM', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
sendStatus(capture, 'writing', 'Writing the answer');
send(capture, 'END_OF_THE_STREAM_ENDER_GAME_42');
expect(screen.getByTestId('stage')).toHaveTextContent('null');
expect(screen.getByTestId('label')).toHaveTextContent('null');
expect(screen.getByTestId('detail')).toHaveTextContent('null');
expect(screen.getByTestId('history-count')).toHaveTextContent('0');
});
it('ignores malformed status frames without throwing', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
send(
capture,
JSON.stringify({ v: 1, type: 'status', data: { stage: 'searching' } }),
);
expect(screen.getByTestId('stage')).toHaveTextContent('null');
});
it('clears activity via clearActivity()', async () => {
const { Harness, capture } = buildHarness();
render(<Harness />);
await act(async () => {});
sendStatus(capture, 'searching', 'Searching the web');
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
act(() => {
screen.getByRole('button', { name: 'clear' }).click();
});
expect(screen.getByTestId('stage')).toHaveTextContent('null');
});
it('clears activity on the streamInterrupted reconnect path', async () => {
const { Harness, capture } = buildHarness();
const { rerender } = render(<Harness reconnectGeneration={0} />);
await act(async () => {});
// Simulate an in-flight generating turn so the reconnect effect treats it as interrupted.
send(capture, 'CONVERSATION_ID');
sendStatus(capture, 'searching', 'Searching the web');
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
rerender(<Harness reconnectGeneration={1} />);
await act(async () => {});
expect(screen.getByTestId('stream-interrupted')).toHaveTextContent('yes');
expect(screen.getByTestId('stage')).toHaveTextContent('null');
});
});
+19 -114
View File
@@ -1,4 +1,4 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { createContext, ReactNode, useContext, useEffect, useRef, useState } from "react";
import { WebSocketContext } from "./WebSocketContext";
import { AccountContext } from "./AccountContext";
import { ConversationContext } from "./ConversationContext";
@@ -6,14 +6,7 @@ import { ConversationPrompt, ConversationPromptType } from "../data";
import { axiosInstance } from "../../axiosApi";
import { AxiosResponse } from "axios";
import { AnalyticsEvents, trackEvent } from "../utils/analytics";
import {
ActivityHistoryEntry,
parseCitationsData,
parseStatusData,
parseVersionedFrame,
} from "../utils/wsFrames";
import { parseChatErrorPayload } from "../utils/chatErrors";
import { sortPrompts } from "../utils/promptOrder";
import { parseCitationsData, parseVersionedFrame } from "../utils/wsFrames";
type MessageProviderProps ={
children? : ReactNode;
@@ -28,12 +21,6 @@ type IMessageContext = {
/** True when a stream was interrupted by a socket drop; cleared on refetch/retry. */
streamInterrupted: boolean;
clearStreamInterrupted: () => void;
/** Live activity status from versioned "status" WS frames (#96). */
activityStage: string | null;
activityLabel: string | null;
activityDetail: string | null;
activityHistory: ActivityHistoryEntry[];
clearActivity: () => void;
}
const initialValues = {
@@ -44,11 +31,6 @@ const initialValues = {
isGeneratingMessage: false,
streamInterrupted: false,
clearStreamInterrupted: () => {},
activityStage: null,
activityLabel: null,
activityDetail: null,
activityHistory: [],
clearActivity: () => {},
}
const MessageContext = createContext<IMessageContext>(initialValues);
@@ -62,40 +44,21 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
const {selectedConversation, setSelectedConversation} = useContext(ConversationContext);
const [stateMessage, setStateMessage] = useState<string>('')
const [conversationDetails, setConversationDetailsState] = useState<ConversationPrompt[]>([])
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false)
const [activityStage, setActivityStage] = useState<string | null>(null)
const [activityLabel, setActivityLabel] = useState<string | null>(null)
const [activityDetail, setActivityDetail] = useState<string | null>(null)
const [activityHistory, setActivityHistory] = useState<ActivityHistoryEntry[]>([])
const messageRef = useRef('')
const messageResponsePart = useRef(0);
const conversationRef = useRef(conversationDetails)
const setConversationDetails = useCallback((next: ConversationPrompt[]) => {
const ordered = sortPrompts(next)
conversationRef.current = ordered
setConversationDetailsState(ordered)
}, [])
const selectedConversationRef = useRef<undefined | number>(undefined)
const isGeneratingRef = useRef(false)
const prevReconnectGenerationRef = useRef(0)
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** Tracks the in-progress activity stage so we can roll it into history with a duration. */
const activeStageRef = useRef<{ stage: string; label: string; startedAt: number } | null>(null)
const clearStreamInterrupted = () => setStreamInterrupted(false)
const clearActivity = () => {
activeStageRef.current = null
setActivityStage(null)
setActivityLabel(null)
setActivityDetail(null)
setActivityHistory([])
}
const GetConversationDetails = useCallback(async (conversationId: number | undefined) => {
async function GetConversationDetails(conversationId: number | undefined) {
if (!conversationId) {
setConversationDetails([])
return
@@ -120,8 +83,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
tokens_out: item.tokens_out ?? null,
citations: item.citations ?? [],
feedback: item.feedback ?? null,
file_type: item.file_type ?? null,
image: item.image ?? null,
}),
)
if (tempConversations.length === 1) {
@@ -130,13 +91,14 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
new ConversationPrompt({ message: '', user_created: false }),
)
}
conversationRef.current = tempConversations
setConversationDetails(tempConversations)
} catch (err) {
console.log('Failed to refetch conversation after reconnect', err)
}
}, [setConversationDetails])
}
const schedulePostStreamRefetch = useCallback(() => {
const schedulePostStreamRefetch = () => {
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
const conversationId = selectedConversationRef.current
if (!conversationId) return
@@ -145,11 +107,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
refetchTimerRef.current = setTimeout(() => {
void GetConversationDetails(conversationId)
}, 800)
}, [GetConversationDetails])
}
useEffect(() => {
void GetConversationDetails(selectedConversation)
}, [selectedConversation, GetConversationDetails])
GetConversationDetails(selectedConversation)
}, [selectedConversation])
useEffect(() => {
return () => {
@@ -180,7 +142,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
setIsGeneratingMessage(false)
setStateMessage('')
setStreamInterrupted(true)
clearActivity()
const details = [...conversationRef.current]
if (details.length > 0) {
@@ -198,9 +159,9 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
}
if (selectedConversationRef.current) {
void GetConversationDetails(selectedConversationRef.current)
GetConversationDetails(selectedConversationRef.current)
}
}, [reconnectGeneration, GetConversationDetails, setConversationDetails])
}, [reconnectGeneration])
useEffect(() => {
/* register a consistent channel name for identifing this chat messages */
@@ -226,66 +187,31 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
conversationRef.current = details
setConversationDetails(details)
schedulePostStreamRefetch()
} else if (frame.type === 'status') {
const statusData = parseStatusData(frame.data)
if (statusData) {
const now = Date.now()
const previous = activeStageRef.current
if (previous && previous.stage !== statusData.stage) {
const durationMs = now - previous.startedAt
setActivityHistory((history) => [
...history,
{
stage: previous.stage,
label: previous.label,
startedAt: previous.startedAt,
finishedAt: now,
},
])
trackEvent(AnalyticsEvents.ACTIVITY_STAGE_COMPLETED, {
stage: previous.stage,
durationMs,
})
}
activeStageRef.current = {
stage: statusData.stage,
label: statusData.label,
startedAt: previous && previous.stage === statusData.stage
? previous.startedAt
: now,
}
setActivityStage(statusData.stage)
// Tokens take over the label once the model starts writing.
setActivityLabel(statusData.stage === 'writing' ? null : statusData.label)
setActivityDetail(statusData.detail ?? null)
}
}
// other unknown types: no-op
// status / unknown types: no-op (compatible with #96)
return
}
if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){
messageResponsePart.current = 0
const details = [...conversationRef.current]
details.pop()
conversationRef.current.pop()
const finalized = new ConversationPrompt({
message: `${messageRef.current}`,
user_created: false,
})
setConversationDetails([...details, finalized])
conversationRef.current = [...conversationRef.current, finalized]
setConversationDetails([...conversationRef.current])
messageRef.current = ''
setStateMessage('')
isGeneratingRef.current = false
setIsGeneratingMessage(false)
setStreamInterrupted(false)
clearActivity()
schedulePostStreamRefetch()
}
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
// Do not copy conversationDetails from this closure. A START frame
// can arrive on the subscription from before the optimistic turn,
// and that copy wiped conversationRef (#136).
conversationRef.current = conversationDetails
isGeneratingRef.current = true
setIsGeneratingMessage(true)
setStreamInterrupted(false)
@@ -297,22 +223,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
setStreamInterrupted(false)
messageResponsePart.current = 1
}else{
if (messageResponsePart.current === 0) {
const err = parseChatErrorPayload(message)
if (err?.content) {
const details = [...conversationRef.current]
const last = details[details.length - 1]
if (last && !last.user_created) {
details[details.length - 1] = new ConversationPrompt({
...last,
message,
})
conversationRef.current = details
setConversationDetails(details)
}
}
return
}
if (messageResponsePart.current === 1){
isGeneratingRef.current = true
setIsGeneratingMessage(true)
@@ -341,7 +251,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
/* unsubscribe from channel during cleanup */
unsubscribe(channelName)
}
}, [account, subscribe, unsubscribe, selectedConversation, setSelectedConversation, schedulePostStreamRefetch, setConversationDetails])
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation])
return(
@@ -353,11 +263,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
isGeneratingMessage,
streamInterrupted,
clearStreamInterrupted,
activityStage,
activityLabel,
activityDetail,
activityHistory,
clearActivity,
}}>
{children}
</MessageContext.Provider>
-1
View File
@@ -29,7 +29,6 @@ describe('ConversationPrompt', () => {
expect(prompt.message).toBe('hello');
expect(prompt.user_created).toBe(true);
expect(prompt.created_timestamp).toEqual(created);
expect(prompt.localKey).toBe('id-7');
});
});
+1 -22
View File
@@ -18,16 +18,10 @@ export interface ConversationPromptType {
tokens_out?: number | null,
citations?: Citation[],
feedback?: PromptFeedbackState | null,
file_type?: string | null,
image?: string | null,
}
let localPromptKey = 0;
export class ConversationPrompt{
id: number | undefined;
/** Stable React key. Optimistic turns have no server id yet (#136). */
localKey: string = '';
message: string = '';
user_created: boolean = false;
created_timestamp: Date = new Date();
@@ -35,15 +29,9 @@ export class ConversationPrompt{
tokens_out: number | null = null;
citations: Citation[] = [];
feedback: PromptFeedbackState | null = null;
file_type: string | null = null;
image: string | null = null;
constructor(initializer?: any){
if(!initializer) {
localPromptKey += 1;
this.localKey = `local-${localPromptKey}`;
return;
}
if(!initializer) return;
if (initializer.id) this.id = initializer.id;
if (initializer.message) this.message = initializer.message;
if (initializer.user_created) this.user_created = initializer.user_created;
@@ -53,14 +41,6 @@ export class ConversationPrompt{
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out;
if (Array.isArray(initializer.citations)) this.citations = initializer.citations;
if (initializer.feedback !== undefined) this.feedback = initializer.feedback;
if (initializer.file_type) this.file_type = initializer.file_type;
if (initializer.image) this.image = initializer.image;
if (initializer.localKey) this.localKey = String(initializer.localKey);
else if (initializer.id) this.localKey = `id-${initializer.id}`;
else {
localPromptKey += 1;
this.localKey = `local-${localPromptKey}`;
}
}
}
@@ -289,7 +269,6 @@ export interface AccountType {
export interface PreferencesType {
order: boolean;
use_conversation_context?: boolean;
}
export class Account {
+3 -3
View File
@@ -6,7 +6,7 @@ import { planAllowsRag, SubscriptionMe } from '../utils/finance';
/**
* Module-level cache so every component that calls useSubscription() during the
* same session shares one /monetization/subscription/ request instead of each
* same session shares one /finance/subscription/ request instead of each
* mounting its own (e.g. Header2 + DocumentStoragePage on the same page).
*/
let cachedSubscription: SubscriptionMe | null = null;
@@ -18,7 +18,7 @@ async function loadSubscription(): Promise<SubscriptionMe | null> {
}
if (!inFlightRequest) {
inFlightRequest = axiosInstance
.get<SubscriptionMe>('/monetization/subscription/')
.get<SubscriptionMe>('/finance/subscription/')
.then((response: AxiosResponse<SubscriptionMe>) => {
cachedSubscription = response.data || null;
return cachedSubscription;
@@ -44,7 +44,7 @@ export type UseSubscriptionResult = {
refresh: () => Promise<void>;
};
/** Fetches /monetization/subscription/ once per session (shared across callers) and exposes plan-derived flags. */
/** Fetches /finance/subscription/ once per session (shared across callers) and exposes plan-derived flags. */
export function useSubscription(): UseSubscriptionResult {
const { authenticated } = useContext(AuthContext);
const [subscription, setSubscription] = useState<SubscriptionMe | null>(cachedSubscription);
@@ -1,35 +0,0 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import AccountDeletionPage from './AccountDeletionPage';
describe('AccountDeletionPage', () => {
it('renders Hesychia deletion steps and retention copy without auth', () => {
render(
<MemoryRouter>
<AccountDeletionPage />
</MemoryRouter>,
);
expect(
screen.getByRole('heading', { name: /Hesychia — Request account deletion/i }),
).toBeInTheDocument();
expect(screen.getByText(/ai\.hesychia\.chat/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /How to delete your account/i }),
).toBeInTheDocument();
expect(screen.getByText(/Delete my account/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Deactivated immediately/i }),
).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Data retained after soft-delete/i }),
).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Retention period/i })).toBeInTheDocument();
expect(screen.getByText(/no automatic hard-purge schedule/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Cannot access your account/i }),
).toBeInTheDocument();
expect(screen.getByText(/30 days/i)).toBeInTheDocument();
});
});
@@ -1,119 +0,0 @@
import React from 'react';
import { Box, Button, Card, CardContent, Divider, Stack, Typography } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import RelatedPages from '../../components/PublicSite/RelatedPages';
import { ACCOUNT_DELETION } from '../../utils/pageProse';
/**
* Public Play / App Store account-deletion instructions (#103).
* No auth required — reviewers and signed-out users must be able to read this page.
*/
const AccountDeletionPage = (): JSX.Element => (
<Box sx={{ py: 4, px: 2, minHeight: '100vh', bgcolor: 'background.default' }}>
<Card sx={{ maxWidth: 900, mx: 'auto', width: '100%' }}>
<CardContent>
<Typography variant="h3" component="h1" gutterBottom>
Hesychia — Request account deletion
</Typography>
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
Hesychia (ai.hesychia.chat) · hesychia.ai · AI ML Operations, LLC
</Typography>
<Typography variant="body1" color="text.secondary">
{ACCOUNT_DELETION.intro}
</Typography>
</CardContent>
<Divider />
<CardContent>
<Stack spacing={3}>
<Box>
<Typography variant="h5" component="h2" gutterBottom>
How to delete your account
</Typography>
<Typography variant="body1" component="ol" sx={{ pl: 2.5, m: 0 }}>
<li>
Open <strong>Hesychia</strong> in the mobile app or at{' '}
<a href="https://hesychia.ai" rel="noopener noreferrer">
hesychia.ai
</a>{' '}
for your account.
</li>
<li>{ACCOUNT_DELETION.signIn}</li>
<li>{ACCOUNT_DELETION.goToAccount}</li>
<li>{ACCOUNT_DELETION.confirm}</li>
</Typography>
</Box>
<Box>
<Typography variant="h5" component="h2" gutterBottom>
What happens when you delete
</Typography>
<Typography variant="h6" component="h3" gutterBottom sx={{ mt: 2 }}>
Deactivated immediately
</Typography>
<Typography variant="body1" component="ul" sx={{ pl: 2.5, m: 0 }}>
<li>{ACCOUNT_DELETION.deactivated}</li>
<li>{ACCOUNT_DELETION.loseAccess}</li>
<li>{ACCOUNT_DELETION.sessions}</li>
</Typography>
<Typography variant="h6" component="h3" gutterBottom sx={{ mt: 2 }}>
Data retained after soft-delete
</Typography>
<Typography variant="body1" paragraph>
{ACCOUNT_DELETION.softDelete} {ACCOUNT_DELETION.remain} {ACCOUNT_DELETION.includes}{' '}
{ACCOUNT_DELETION.purposes}
</Typography>
<Typography variant="h6" component="h3" gutterBottom>
Retention period
</Typography>
<Typography variant="body1" paragraph sx={{ mb: 0 }}>
{ACCOUNT_DELETION.retention} {ACCOUNT_DELETION.request} {ACCOUNT_DELETION.noClaim}
</Typography>
</Box>
<Box>
<Typography variant="h5" component="h2" gutterBottom>
Cannot access your account?
</Typography>
<Typography variant="body1" paragraph>
If you cannot sign in to complete in-app deletion, contact AI ML Operations, LLC via{' '}
<a href="https://www.aimloperations.com" rel="noopener noreferrer">
aimloperations.com
</a>
. {ACCOUNT_DELETION.email} {ACCOUNT_DELETION.days}
</Typography>
<Typography variant="body2" color="text.secondary">
{ACCOUNT_DELETION.staff}
</Typography>
</Box>
</Stack>
</CardContent>
<Divider />
<CardContent>
<Typography variant="body2" color="text.secondary">
Already have an account?{' '}
<Button component={RouterLink} to="/signin/" size="small">
Sign in
</Button>{' '}
·{' '}
<Button component={RouterLink} to="/account/" size="small">
Account
</Button>{' '}
·{' '}
<Button component={RouterLink} to="/terms_of_service/" size="small">
Terms of Service
</Button>
</Typography>
</CardContent>
</Card>
<RelatedPages />
</Box>
);
export default AccountDeletionPage;
@@ -20,7 +20,6 @@ import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
import { exportChat, type ExportFormat } from "../../utils/export/exportChat";
import { showToast } from "../../utils/toastBus";
import type { PromptRating } from "../../utils/promptFeedback";
import { CHAT_FILE_ACCEPT, imageSrcForPrompt, isImageFile } from "../../utils/visionAttachment";
// Styled Components
const PageContainer = styled.div`
@@ -253,6 +252,32 @@ const IconButton = styled.button`
}
`;
const StyledSelect = styled.select`
background: transparent;
border: none;
flex-shrink: 0;
max-width: 7rem;
color: ${({ theme }) => theme.colors.text};
font-size: 0.9rem;
padding: 0.5rem;
outline: none;
cursor: pointer;
margin-right: 0.5rem;
border-right: 1px solid ${({ theme }) => theme.colors.cardBorder};
@media (max-width: 768px) {
max-width: 5.5rem;
font-size: 0.8rem;
padding: 0.4rem 0.2rem;
margin-right: 0.25rem;
}
option {
background: ${({ theme }) => theme.colors.background || '#1a1a1a'};
color: ${({ theme }) => theme.colors.text};
}
`;
const ConversationItem = styled.div<{ $active: boolean }>`
padding: 0.8rem 1rem;
margin-bottom: 0.5rem;
@@ -318,13 +343,11 @@ const validationSchema = Yup.object().shape({
.required("This is required"),
});
/** Fixed until product re-exposes a model picker (#106). */
const DEFAULT_MODEL_NAME = "THINKING";
type PromptValues = {
prompt: string;
file: Blob | null;
fileType: string | null;
modelName: string;
};
const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.Element => {
@@ -335,46 +358,6 @@ const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.El
return <div ref={elementRef} />;
};
const AttachmentPreview = ({ file }: { file: Blob | null }): JSX.Element | null => {
const theme = useContext(ThemeContext);
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!file || !isImageFile(file)) {
setUrl(null);
return;
}
const next = URL.createObjectURL(file);
setUrl(next);
return () => URL.revokeObjectURL(next);
}, [file]);
if (url) {
return (
<img
src={url}
alt=""
style={{
display: "block",
margin: "0.5rem auto 0",
maxHeight: 120,
maxWidth: "100%",
borderRadius: 8,
}}
/>
);
}
if (!file) return null;
return (
<div style={{
marginTop: "0.5rem",
fontSize: "0.8rem",
color: theme?.darkMode ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.6)",
textAlign: "center",
}}>
Attached: {file instanceof File ? file.name : "attachment"}
</div>
);
};
const AsyncDashboardInner = (): JSX.Element => {
const [, , , sendMessage, isConnected, connectionStatus] =
useContext(WebSocketContext);
@@ -387,11 +370,6 @@ const AsyncDashboardInner = (): JSX.Element => {
setConversationDetails,
stateMessage,
streamInterrupted,
activityStage,
activityLabel,
activityDetail,
activityHistory,
clearActivity,
} = useContext(MessageContext);
const conversationRef = useRef(conversationDetails);
@@ -464,7 +442,7 @@ const AsyncDashboardInner = (): JSX.Element => {
})();
const handlePromptSubmit = async (
{ prompt, file, fileType }: PromptValues,
{ prompt, file, fileType, modelName }: PromptValues,
{ resetForm }: any,
): Promise<void> => {
const trimmedPrompt = (prompt ?? "").trim();
@@ -472,22 +450,15 @@ const AsyncDashboardInner = (): JSX.Element => {
return;
}
try {
clearActivity();
const attachedImage = file && isImageFile(file) ? URL.createObjectURL(file) : null;
const tempConversations: ConversationPrompt[] = [
...conversationDetails,
new ConversationPrompt({
message: trimmedPrompt,
user_created: true,
image: attachedImage,
file_type: fileType,
}),
new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
new ConversationPrompt({ message: "", user_created: false }),
];
conversationRef.current = tempConversations;
setConversationDetails(tempConversations);
sendMessage(trimmedPrompt, selectedConversation, file, fileType, DEFAULT_MODEL_NAME);
sendMessage(trimmedPrompt, selectedConversation, file, fileType, modelName);
trackEvent(AnalyticsEvents.MESSAGE_SENT, {
hasConversation: Boolean(selectedConversation),
hasAttachment: Boolean(file),
@@ -497,6 +468,7 @@ const AsyncDashboardInner = (): JSX.Element => {
prompt: "",
file: null,
fileType: null,
modelName: modelName, // Keep the selected model
}
});
@@ -504,8 +476,6 @@ const AsyncDashboardInner = (): JSX.Element => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
const fileInput = document.getElementById("file-upload") as HTMLInputElement | null;
if (fileInput) fileInput.value = "";
} catch (e) {
console.log(`error ${e}`);
}
@@ -599,6 +569,7 @@ const AsyncDashboardInner = (): JSX.Element => {
{(
[
['pdf', 'PDF'],
['docx', 'Word (.docx)'],
['csv', 'CSV'],
['xlsx', 'Excel (.xlsx)'],
['txt', 'Plain text'],
@@ -622,7 +593,7 @@ const AsyncDashboardInner = (): JSX.Element => {
: convo_detail.message;
return (
<ConversationDetailCard
key={convo_detail.localKey}
key={convo_detail.id || index}
message={displayMessage}
user_created={convo_detail.user_created}
promptId={convo_detail.id}
@@ -635,12 +606,6 @@ const AsyncDashboardInner = (): JSX.Element => {
onRatingChange={(rating) =>
updatePromptRating(convo_detail.id, rating)
}
activityStage={isLiveStream ? activityStage : null}
activityLabel={isLiveStream ? activityLabel : null}
activityDetail={isLiveStream ? activityDetail : null}
activityHistory={isLiveStream ? activityHistory : []}
activityInterrupted={isLiveStream && streamInterrupted}
imageSrc={imageSrcForPrompt(convo_detail.image, convo_detail.file_type)}
/>
);
})
@@ -665,6 +630,7 @@ const AsyncDashboardInner = (): JSX.Element => {
prompt: "",
file: null,
fileType: null,
modelName: "FAST",
}}
validationSchema={validationSchema}
onSubmit={handlePromptSubmit}
@@ -702,7 +668,7 @@ const AsyncDashboardInner = (): JSX.Element => {
<VisuallyHiddenInput
id="file-upload"
type="file"
accept={CHAT_FILE_ACCEPT}
accept=".csv,.xlsx,.txt,.pdf,.PDF"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
@@ -715,6 +681,14 @@ const AsyncDashboardInner = (): JSX.Element => {
}}
/>
<Field
as={StyledSelect}
name="modelName"
>
<option value="FAST">FAST</option>
<option value="THINKING">THINKING</option>
</Field>
<Field name="prompt">
{({ field }: any) => (
<StyledInput
@@ -753,7 +727,16 @@ const AsyncDashboardInner = (): JSX.Element => {
</span>
</Tooltip>
</StyledInputContainer>
<AttachmentPreview file={formik.values.file} />
{formik.values.file && (
<div style={{
marginTop: '0.5rem',
fontSize: '0.8rem',
color: theme?.darkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)',
textAlign: 'center'
}}>
Attached: {(formik.values.file as File).name}
</div>
)}
</Form>
)}
</Formik>
@@ -133,7 +133,7 @@ describe('AuthCallback', () => {
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/monetization/checkout/',
'/finance/checkout/',
expect.objectContaining({
success_url: expect.stringContaining('/billing/success'),
cancel_url: expect.stringContaining('/billing/cancel'),
@@ -181,7 +181,7 @@ const AuthCallback = (): JSX.Element => {
setStatusText('Starting checkout…');
const { success_url, cancel_url } = checkoutReturnUrls();
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup' });
const checkoutResponse = await axiosInstance.post('/monetization/checkout/', {
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
success_url,
cancel_url,
});
@@ -1,27 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import ChangelogPage from './ChangelogPage';
import { CHANGELOG_ENTRIES } from './changelogEntries';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
describe('ChangelogPage', () => {
it('lists dated shipped notes and no founder', () => {
render(
<MemoryRouter>
<ChangelogPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Changelog' })).toBeInTheDocument();
expect(screen.getAllByText('2026-08-04')).toHaveLength(2);
expect(screen.getAllByText('2026-08-01')).toHaveLength(2);
for (const entry of CHANGELOG_ENTRIES) {
expect(screen.getByRole('heading', { name: entry.title })).toBeInTheDocument();
}
expect(screen.getByText(/Conversation memory/)).toBeInTheDocument();
expect(screen.queryByText(/founder/i)).not.toBeInTheDocument();
expect(CHANGELOG_ENTRIES.every((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date))).toBe(true);
});
});
@@ -1,62 +0,0 @@
import React from 'react';
import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { CHANGELOG_ENTRIES } from './changelogEntries';
import { publicLine, publicText } from '../../components/PublicSite/publicColors';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
margin: 0 0 1.5rem;
`;
const List = styled.ol`
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 1.25rem;
`;
const Item = styled.li`
border-top: 1px solid ${publicLine};
padding-top: 1rem;
`;
const When = styled.time`
display: block;
color: ${publicText};
opacity: 0.65;
font-size: 0.9rem;
margin-bottom: 0.35rem;
`;
const Heading = styled.h2`
font-size: 1.15rem;
margin: 0 0 0.35rem;
`;
const Note = styled.p`
margin: 0;
color: ${publicText};
line-height: 1.5;
max-width: 40rem;
`;
const ChangelogPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Title>Changelog</Title>
<List>
{CHANGELOG_ENTRIES.map((entry) => (
<Item key={`${entry.date}-${entry.title}`}>
<When dateTime={entry.date}>{entry.date}</When>
<Heading>{entry.title}</Heading>
<Note>{entry.note}</Note>
</Item>
))}
</List>
</PublicSiteLayout>
);
};
export default ChangelogPage;
@@ -1,31 +0,0 @@
import { CHANGELOG } from '../../utils/pageProse';
export type ChangelogEntry = {
date: string;
title: string;
note: string;
};
/** Shipped product changes only. Dates match the git history. No founder byline. */
export const CHANGELOG_ENTRIES: readonly ChangelogEntry[] = [
{
date: '2026-08-04',
title: 'Conversation memory',
note: `${CHANGELOG.memory} ${CHANGELOG.memoryOff}`,
},
{
date: '2026-08-04',
title: 'Account deletion',
note: CHANGELOG.deletion,
},
{
date: '2026-08-01',
title: 'Documents',
note: CHANGELOG.documents,
},
{
date: '2026-08-01',
title: 'Subscriptions',
note: CHANGELOG.subscriptions,
},
];
@@ -60,7 +60,7 @@ const subscriptionResponse = (rag: boolean) => ({
const mockGetByUrl = (overrides: { rag: boolean }) => {
mockGet.mockImplementation((url: string) => {
if (url === '/monetization/subscription/') {
if (url === '/finance/subscription/') {
return Promise.resolve(subscriptionResponse(overrides.rag));
}
if (typeof url === 'string' && url.startsWith('/documents/')) {
@@ -1,138 +0,0 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import LandingPage from './LandingPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
const mockGet = jest.fn();
const mockPost = jest.fn();
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
},
}));
const settings = (enabled: boolean) => ({ data: { enable_account_registration: enabled } });
const usage = {
data: {
account_count: 3,
document_count: 12,
as_of: '2026-10-17T15:00:00+00:00',
},
};
describe('LandingPage', () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
});
it('says what Hesychia is and offers a waitlist when registration is off', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(false));
if (url === '/public/usage/') return Promise.resolve(usage);
return Promise.reject(new Error(url));
});
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Hesychia is a calm AI assistant.' })).toBeInTheDocument();
expect(screen.getByText(/Quiet, focused AI chat — private by default/i)).toBeInTheDocument();
expect(screen.getByAltText('Hesychia logo')).toBeInTheDocument();
expect(screen.getByText(/Your documents, stored and searchable/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Business accounts' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Roadmap' })).toBeInTheDocument();
expect(screen.getByText(/Image generation is on the roadmap/i)).toBeInTheDocument();
expect(screen.getByText(/Hesychia reads the picture/i)).toBeInTheDocument();
expect(
screen.getByText(/Early adopters pay \$10 per month, locked for the lifetime of the account/i),
).toBeInTheDocument();
expect(screen.getByText(/We do not sell your data/i)).toBeInTheDocument();
expect(screen.getByText(/Canceling does not delete documents/i)).toBeInTheDocument();
expect(screen.queryByText(/founder/i)).not.toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Join the waitlist' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Privacy details' })).toHaveAttribute('href', '/privacy/');
expect(screen.getAllByRole('link', { name: 'Sign in' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('link', { name: 'Pricing' })[0]).toHaveAttribute('href', '/pricing/');
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/public/usage/'));
expect(screen.queryByLabelText('Usage')).not.toBeInTheDocument();
expect(screen.queryByText(/3 accounts/)).not.toBeInTheDocument();
expect(screen.queryByText(/12 documents/)).not.toBeInTheDocument();
});
it('shows rounded account and document counts once there are 20 accounts', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(false));
if (url === '/public/usage/') {
return Promise.resolve({
data: { account_count: 23, document_count: 156, as_of: '2026-10-17T15:00:00+00:00' },
});
}
return Promise.reject(new Error(url));
});
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
expect(await screen.findByText(/20\+ accounts/)).toBeInTheDocument();
expect(screen.getByText(/100\+ documents stored/)).toBeInTheDocument();
expect(screen.getByText('2026-10-17')).toBeInTheDocument();
expect(screen.queryByText(/23 accounts/)).not.toBeInTheDocument();
expect(screen.queryByText(/156 documents/)).not.toBeInTheDocument();
});
it('links to create account when registration is on', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(true));
return Promise.reject(new Error(url));
});
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
const create = await screen.findByRole('link', { name: 'Create account' });
expect(create).toHaveAttribute('href', '/signup/');
expect(screen.queryByRole('button', { name: 'Join the waitlist' })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Usage')).not.toBeInTheDocument();
});
it('stores a waitlist email', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(false));
return Promise.reject(new Error(url));
});
mockPost.mockResolvedValue({ data: { detail: "You're on the list." } });
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
const user = userEvent.setup();
await user.type(await screen.findByLabelText('Email'), 'person@example.com');
await user.click(screen.getByRole('button', { name: 'Join the waitlist' }));
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/public/waitlist/', { email: 'person@example.com' });
});
expect(await screen.findByRole('status')).toHaveTextContent("You're on the list.");
});
});
@@ -1,152 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
import GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import UsageCounts from '../../components/PublicSite/UsageCounts';
import { publicAccent, publicCard, publicLine, publicText } from '../../components/PublicSite/publicColors';
import { HOME } from '../../utils/pageProse';
const Hero = styled.header`
max-width: 40rem;
`;
const Mark = styled.img`
width: 4rem;
height: 4rem;
border-radius: 50%;
`;
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.15;
margin: 1.25rem 0 0.75rem;
font-weight: 700;
`;
const Lede = styled.p`
font-size: 1.25rem;
line-height: 1.45;
margin: 0 0 0.75rem;
color: ${publicText};
`;
const Memory = styled.p`
font-size: 1.05rem;
line-height: 1.5;
margin: 0 0 1.5rem;
color: ${publicText};
opacity: 0.85;
`;
const Privacy = styled.section`
margin-top: 1.75rem;
max-width: 40rem;
`;
const PrivacyList = styled.ul`
margin: 0.5rem 0 0.75rem;
padding-left: 1.2rem;
color: ${publicText};
opacity: 0.85;
line-height: 1.5;
`;
const TextLink = styled(Link)`
color: ${publicAccent};
`;
const Features = styled.section`
margin-top: 3rem;
display: grid;
gap: 1rem;
`;
const Feature = styled.article`
border: 1px solid ${publicLine};
border-radius: 0.75rem;
padding: 1.25rem 1.35rem;
background: ${publicCard};
`;
const FeatureTitle = styled.h2`
font-size: 1.15rem;
margin: 0 0 0.4rem;
`;
const FeatureBody = styled.p`
margin: 0;
color: ${publicText};
opacity: 0.85;
line-height: 1.5;
`;
const PriceLock = styled.p`
font-size: 1.05rem;
line-height: 1.5;
margin: 0 0 1.5rem;
color: ${publicText};
font-weight: 600;
`;
const Roadmap = styled.section`
margin-top: 1rem;
border: 1px solid ${publicLine};
border-radius: 0.75rem;
padding: 1.25rem 1.35rem;
background: ${publicCard};
display: grid;
gap: 0.75rem;
`;
const RoadmapTitle = styled.h2`
font-size: 1.15rem;
margin: 0;
`;
const LandingPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Hero>
<Mark src={hesychiaMark} alt="Hesychia logo" />
<Title>Hesychia is a calm AI assistant.</Title>
<Lede>{HOME.lede}</Lede>
<Memory>{HOME.memory}</Memory>
<PriceLock>{HOME.earlyAdopter}</PriceLock>
<GetStarted source="landing" />
<Privacy aria-label="Privacy">
<h2>What we do with your data</h2>
<PrivacyList>
<li>{HOME.stored}</li>
<li>{HOME.notSold}</li>
<li>{HOME.cancel}</li>
</PrivacyList>
<TextLink to="/privacy/">Privacy details</TextLink>
</Privacy>
<UsageCounts />
</Hero>
<Features id="product" aria-label="Product">
<Feature>
<FeatureTitle>Memory that stays with the work</FeatureTitle>
<FeatureBody>{HOME.memoryFeature}</FeatureBody>
</Feature>
<Feature>
<FeatureTitle>Your documents, stored and searchable</FeatureTitle>
<FeatureBody>{HOME.documentsFeature}</FeatureBody>
</Feature>
<Feature>
<FeatureTitle>Business accounts</FeatureTitle>
<FeatureBody>{HOME.businessFeature}</FeatureBody>
</Feature>
<Roadmap aria-labelledby="roadmap-title">
<RoadmapTitle id="roadmap-title">Roadmap</RoadmapTitle>
<FeatureBody>{HOME.roadmapImage}</FeatureBody>
<FeatureBody>{HOME.roadmapVision}</FeatureBody>
</Roadmap>
</Features>
</PublicSiteLayout>
);
};
export default LandingPage;
@@ -3,13 +3,29 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import PasswordReset from './PasswordReset';
import { RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY } from './recaptchaV3';
const mockPost = jest.fn();
const mockExecute = jest.fn();
const mockExecuteAsync = jest.fn();
const mockReset = jest.fn();
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('react-google-recaptcha', () => {
const ReactLib = require('react');
return ReactLib.forwardRef(
(
_props: unknown,
ref: React.Ref<{ executeAsync: () => Promise<string>; reset: () => void }>
) => {
ReactLib.useImperativeHandle(ref, () => ({
executeAsync: mockExecuteAsync,
reset: mockReset,
}));
return <div data-testid="recaptcha" />;
}
);
});
jest.mock('../../../axiosApi', () => ({
cleanAxiosInstance: {
post: (...args: unknown[]) => mockPost(...args),
@@ -32,23 +48,13 @@ const renderPasswordReset = () =>
describe('PasswordReset', () => {
beforeEach(() => {
mockPost.mockReset();
mockExecute.mockReset();
mockExecute.mockResolvedValue('captcha-token');
mockExecuteAsync.mockReset();
mockReset.mockReset();
mockExecuteAsync.mockResolvedValue('captcha-token');
mockPost.mockResolvedValue({ status: 200 });
window.grecaptcha = {
ready: (callback) => callback(),
execute: mockExecute,
};
});
afterEach(() => {
delete window.grecaptcha;
document
.querySelectorAll('script[data-recaptcha-v3="true"]')
.forEach((node) => node.remove());
});
it('submits email with a v3 captcha token and navigates to confirmation', async () => {
it('submits email with captcha token and navigates to confirmation', async () => {
const user = userEvent.setup();
renderPasswordReset();
@@ -56,9 +62,6 @@ describe('PasswordReset', () => {
await user.click(screen.getByRole('button', { name: 'Reset Password' }));
await waitFor(() => {
expect(mockExecute).toHaveBeenCalledWith(RECAPTCHA_SITE_KEY, {
action: RECAPTCHA_ACTION,
});
expect(mockPost).toHaveBeenCalledWith('user/reset_password/', {
email: 'user@example.com',
recaptchaToken: 'captcha-token',
@@ -67,20 +70,6 @@ describe('PasswordReset', () => {
expect(await screen.findByText('Confirmation page')).toBeInTheDocument();
});
it('shows error when captcha fails and does not post', async () => {
mockExecute.mockRejectedValue(new Error('invalid site key'));
const user = userEvent.setup();
renderPasswordReset();
await user.type(screen.getByPlaceholderText('Email Address'), 'user@example.com');
await user.click(screen.getByRole('button', { name: 'Reset Password' }));
expect(
await screen.findByText('Unable to reset password. Please try again.')
).toBeInTheDocument();
expect(mockPost).not.toHaveBeenCalled();
});
it('shows error when request fails', async () => {
mockPost.mockRejectedValue(new Error('network'));
const user = userEvent.setup();
@@ -1,11 +1,11 @@
import { Form, Formik, Field } from 'formik';
import React, { useEffect, useState } from 'react';
import React, { useRef, useState } from 'react';
import { cleanAxiosInstance } from '../../../axiosApi';
import { useNavigate } from 'react-router-dom';
import ReCAPTCHA from 'react-google-recaptcha';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import styled from 'styled-components';
import * as Yup from 'yup';
import { executePasswordResetCaptcha, loadRecaptcha } from './recaptchaV3';
const PageContainer = styled.div`
position: relative;
@@ -139,20 +139,20 @@ const validationSchema = Yup.object().shape({
const PasswordReset = (): JSX.Element => {
const navigate = useNavigate();
const recaptchaRef = useRef<ReCAPTCHA>(null);
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
loadRecaptcha().catch(() => {
// Submit reports a failure if the v3 script never becomes ready.
});
}, []);
const handlePasswordResetEmail = async ({
email,
}: EmailPasswordResetValues): Promise<void> => {
setErrorMessage('');
try {
const token = await executePasswordResetCaptcha();
let token = '';
if (recaptchaRef.current) {
token = (await recaptchaRef.current.executeAsync()) || '';
recaptchaRef.current.reset();
}
await cleanAxiosInstance.post('user/reset_password/', {
email,
recaptchaToken: token,
@@ -186,6 +186,14 @@ const PasswordReset = (): JSX.Element => {
placeholder="Email Address"
type="email"
/>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<ReCAPTCHA
ref={recaptchaRef}
sitekey="6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ"
size="invisible"
theme="dark"
/>
</div>
<StyledButton type="submit" disabled={formik.isSubmitting}>
Reset Password
</StyledButton>
@@ -1,53 +0,0 @@
import { RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY, executePasswordResetCaptcha, loadRecaptcha } from './recaptchaV3';
describe('recaptchaV3', () => {
afterEach(() => {
delete window.grecaptcha;
document
.querySelectorAll('script[data-recaptcha-v3="true"]')
.forEach((node) => node.remove());
});
it('loads the v3 script once when grecaptcha is missing', async () => {
const pending = loadRecaptcha();
const again = loadRecaptcha();
const scripts = document.querySelectorAll('script[data-recaptcha-v3="true"]');
expect(scripts).toHaveLength(1);
expect(scripts[0]).toHaveAttribute(
'src',
`https://www.google.com/recaptcha/api.js?render=${RECAPTCHA_SITE_KEY}`
);
window.grecaptcha = {
ready: (callback) => callback(),
execute: jest.fn(),
};
scripts[0].dispatchEvent(new Event('load'));
await expect(pending).resolves.toBeUndefined();
await expect(again).resolves.toBeUndefined();
});
it('executes the password_reset action', async () => {
const execute = jest.fn().mockResolvedValue('token-1');
window.grecaptcha = {
ready: (callback) => callback(),
execute,
};
await expect(executePasswordResetCaptcha()).resolves.toBe('token-1');
expect(execute).toHaveBeenCalledWith(RECAPTCHA_SITE_KEY, {
action: RECAPTCHA_ACTION,
});
expect(document.querySelector('script[data-recaptcha-v3="true"]')).toBeNull();
});
it('rejects an empty token', async () => {
window.grecaptcha = {
ready: (callback) => callback(),
execute: jest.fn().mockResolvedValue(''),
};
await expect(executePasswordResetCaptcha()).rejects.toThrow('empty reCAPTCHA token');
});
});
@@ -1,59 +0,0 @@
export const RECAPTCHA_SITE_KEY = '6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ';
export const RECAPTCHA_ACTION = 'password_reset';
type Grecaptcha = {
ready: (callback: () => void) => void;
execute: (siteKey: string, options: { action: string }) => Promise<string>;
};
declare global {
interface Window {
grecaptcha?: Grecaptcha;
}
}
let loading: Promise<void> | null = null;
export function loadRecaptcha(siteKey: string = RECAPTCHA_SITE_KEY): Promise<void> {
if (window.grecaptcha) {
return Promise.resolve();
}
if (loading && document.querySelector('script[data-recaptcha-v3="true"]')) {
return loading;
}
loading = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}`;
script.async = true;
script.dataset.recaptchaV3 = 'true';
script.onload = () => resolve();
script.onerror = () => {
loading = null;
script.remove();
reject(new Error('reCAPTCHA failed to load'));
};
document.head.appendChild(script);
});
return loading;
}
export async function executePasswordResetCaptcha(
siteKey: string = RECAPTCHA_SITE_KEY
): Promise<string> {
await loadRecaptcha(siteKey);
const grecaptcha = window.grecaptcha;
if (!grecaptcha) {
throw new Error('reCAPTCHA unavailable');
}
await new Promise<void>((resolve) => {
grecaptcha.ready(resolve);
});
const token = await grecaptcha.execute(siteKey, { action: RECAPTCHA_ACTION });
if (!token) {
throw new Error('empty reCAPTCHA token');
}
return token;
}
@@ -1,32 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import PricingPage from './PricingPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
get: () => Promise.resolve({ data: { enable_account_registration: false } }),
post: jest.fn(),
},
}));
describe('PricingPage', () => {
it('states the public $10 price and the waitlist', async () => {
render(
<MemoryRouter>
<PricingPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Pricing' })).toBeInTheDocument();
expect(screen.getByText(/\$10\/month, flat, with no ads, no upsells, and one price/)).toBeInTheDocument();
expect(
screen.getByText(/Early adopters pay \$10 per month, locked for the lifetime of the account/i),
).toBeInTheDocument();
expect(screen.getByText(/that price never goes up/i)).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Join the waitlist' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'See pricing' })).not.toBeInTheDocument();
});
});
@@ -1,46 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
import { PRICING } from '../../utils/pageProse';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
margin: 0 0 0.75rem;
`;
const Price = styled.p`
font-size: 1.35rem;
margin: 0 0 0.5rem;
`;
const Body = styled.p`
color: ${publicText};
line-height: 1.5;
max-width: 36rem;
`;
const Back = styled(Link)`
color: ${publicAccent};
`;
const PricingPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Title>Pricing</Title>
<Price>{PRICING.price}</Price>
<Body>{PRICING.lock}</Body>
<Body>
{PRICING.plan} {PRICING.compare}
</Body>
<GetStarted source="pricing" idPrefix="pricing-waitlist" showPricingLink={false} />
<p>
<Back to="/">Back to Hesychia</Back>
</p>
</PublicSiteLayout>
);
};
export default PricingPage;
@@ -1,27 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import PrivacyPage from './PrivacyPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
describe('PrivacyPage', () => {
it('states storage, no ads, and that cancel does not delete documents', () => {
render(
<MemoryRouter>
<PrivacyPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Privacy' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'What is stored' })).toBeInTheDocument();
expect(screen.getByText(/do not sell your information/i)).toBeInTheDocument();
expect(screen.getByText(/do not use advertising cookies/i)).toBeInTheDocument();
expect(screen.getByText(/does not delete uploaded documents/i)).toBeInTheDocument();
expect(screen.getByText(/soft-delete/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /how account deletion works/i })).toHaveAttribute(
'href',
'/account-deletion/',
);
});
});
@@ -1,64 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
import { PRIVACY } from '../../utils/pageProse';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
margin: 0 0 1rem;
`;
const Section = styled.section`
margin-bottom: 1.5rem;
max-width: 40rem;
`;
const Heading = styled.h2`
font-size: 1.2rem;
margin: 0 0 0.4rem;
`;
const Body = styled.p`
margin: 0;
color: ${publicText};
line-height: 1.55;
`;
const TextLink = styled(Link)`
color: ${publicAccent};
`;
const PrivacyPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Title>Privacy</Title>
<Section>
<Heading>What is stored</Heading>
<Body>
{PRIVACY.stored} {PRIVACY.analytics}
</Body>
</Section>
<Section>
<Heading>What we do not do</Heading>
<Body>
{PRIVACY.notSold} {PRIVACY.thirdParties} The{' '}
<TextLink to="/terms_of_service/#data-confidentiality">Terms of Service</TextLink> and
the <TextLink to="/terms_of_service/#analytics">analytics section</TextLink> are the
binding description.
</Body>
</Section>
<Section>
<Heading>If you cancel</Heading>
<Body>
{PRIVACY.cancel} {PRIVACY.documents} {PRIVACY.softDelete} {PRIVACY.purge} See{' '}
<TextLink to="/account-deletion/">how account deletion works</TextLink>, including the
soft-delete steps and what data may remain.
</Body>
</Section>
</PublicSiteLayout>
);
};
export default PrivacyPage;
+3 -18
View File
@@ -79,10 +79,10 @@ describe('SignIn', () => {
expect(await screen.findByRole('button', { name: /Don't have an account\? Sign up/i })).toBeInTheDocument();
});
it('shows SSO buttons when registration enabled and oauth providers configured', async () => {
it('shows SSO buttons when oauth providers configured', async () => {
mockGet.mockResolvedValue({
data: {
enable_account_registration: true,
enable_account_registration: false,
oauth: { google: true, microsoft: true },
},
});
@@ -92,25 +92,10 @@ describe('SignIn', () => {
expect(screen.getByRole('button', { name: 'Sign in with Microsoft' })).toBeInTheDocument();
});
it('hides SSO buttons when registration disabled even if oauth configured', async () => {
mockGet.mockResolvedValue({
data: {
enable_account_registration: false,
oauth: { google: true, microsoft: true },
},
});
renderSignIn();
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Continue with Google' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Sign in with Microsoft' })).not.toBeInTheDocument();
});
});
it('hides SSO buttons when oauth not configured', async () => {
mockGet.mockResolvedValue({
data: {
enable_account_registration: true,
enable_account_registration: false,
oauth: { google: false, microsoft: false },
},
});
+6 -8
View File
@@ -264,7 +264,7 @@ const SignIn = (): JSX.Element => {
<ContentWrapper>
<GlassCard>
<BrandBlock>
<BrandMark src={hesychiaMark} alt="Hesychia logo" />
<BrandMark src={hesychiaMark} alt="" />
<BrandWordmark>Hesychia.ai</BrandWordmark>
</BrandBlock>
<CardTitle>Sign In</CardTitle>
@@ -294,13 +294,11 @@ const SignIn = (): JSX.Element => {
<StyledButton type="submit" disabled={formik.isSubmitting}>
Sign In
</StyledButton>
{registrationEnabled && (
<SsoButtons
intent="login"
providers={oauthProviders}
disabled={formik.isSubmitting}
/>
)}
<SsoButtons
intent="login"
providers={oauthProviders}
disabled={formik.isSubmitting}
/>
</Form>
)}
</Formik>
@@ -139,7 +139,7 @@ describe('SignUp', () => {
});
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/monetization/checkout/', {
expect(mockPost).toHaveBeenCalledWith('/finance/checkout/', {
success_url: 'http://localhost/billing/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost/billing/cancel',
});
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more