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
127 changed files with 892 additions and 5858 deletions

No files matched your search

-58
View File
@@ -36,61 +36,3 @@ jobs:
--app chat_web_app \
--env prod \
--ref "${{ gitea.sha }}"
# Same commit the workflow was started from (the branch picked in Run workflow).
android-release:
needs: deploy-prod
runs-on: self-hosted
permissions:
contents: write
defaults:
run:
working-directory: llm-fe
steps:
- uses: actions/checkout@v4
with:
ref: ${{ gitea.sha }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Resolve Play version
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_SHA: ${{ gitea.sha }}
run: node scripts/android-version.mjs
- name: Build store web bundle
run: npm run build:android:store
- name: Sign with the Play upload key
run: |
set -euo pipefail
test -f /home/westfarn/.android/hesychia-keystore.properties
test -f /home/westfarn/.android/hesychia-upload.jks
test -d /home/westfarn/Android/Sdk
ln -sfn /home/westfarn/.android/hesychia-keystore.properties android/keystore.properties
printf 'sdk.dir=%s\n' /home/westfarn/Android/Sdk > android/local.properties
- name: Package signed app bundle
run: |
set -euo pipefail
cd android
chmod +x ./gradlew
./gradlew bundleRelease \
-PhesychiaVersionCode="$HESYCHIA_VERSION_CODE" \
-PhesychiaVersionName="$HESYCHIA_VERSION_NAME"
jarsigner -verify app/build/outputs/bundle/release/app-release.aab >/tmp/jarsigner-aab.txt 2>&1
echo "jar verified"
- name: Publish Gitea release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_SHA: ${{ gitea.sha }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
run: node scripts/publish-android-gitea-release.mjs
-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
+21 -64
View File
@@ -40,47 +40,28 @@ 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
`android/app/build.gradle` keeps a floor:
In `android/app/build.gradle`:
- `hesychiaVersionNameFloor` — `"0.1.0"` (user-visible string)
- `hesychiaVersionCodeFloor` — `1` (integer; Play requires a higher code on every upload)
- **versionName** — user-visible string; keep aligned with `package.json` `version` (currently `0.1.0`)
- **versionCode** — integer; **must increase** for every Play Store upload
Local `./gradlew bundleRelease` uses that floor when you do not pass properties. **Deploy Prod** does not edit the file. After the web deploy it looks up existing `android-v*` Gitea releases, reuses the pair when the tag already points at this commit, otherwise ships the next patch and the next `versionCode` via `-PhesychiaVersionName` / `-PhesychiaVersionCode`.
Raise the floor in git only when you want the next automatic release to jump ahead of older tags.
## Deploy Prod publishes the Play bundle
**Deploy Prod** (`.gitea/workflows/deploy-prod.yml`) runs `android-release` after the prod web deploy of the same commit. The job:
1. Resolves the Play version (`llm-fe/scripts/android-version.mjs`)
2. `npm run build:android:store` — `.env.mobile` (prod API) plus `cap sync android`
3. Signs with the upload keystore on the runner (`~/.android/`, not in git)
4. `jarsigner -verify` on `app-release.aab`
5. Creates or reuses a Gitea release tagged `android-v<versionName>-<versionCode>` and attaches `hesychia-<versionName>-<versionCode>.aab`
Example of the same shape on RealPath: [android-v1.0.2-3](https://git.aimloperations.com/Ditch_The_Agent/dta_webapp/releases/tag/android-v1.0.2-3).
Download that AAB from the Gitea release and upload it to the Play **internal** track. Deploy Prod does not call the Play Developer API. A separate **Deploy Android** workflow is not used ([#58](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/58) is superseded by this job, [#121](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/121)).
Manual store binary (same web bundle the job builds):
```bash
cd llm-fe
npm run build:android:store
cd android
./gradlew bundleRelease
```
Bump both before each store release.
## Icons / splash
@@ -101,39 +82,23 @@ Same command also regenerates iOS `Assets.xcassets` (see [`IOS.md`](IOS.md)).
## Signing (upload keystore)
**Never commit keystores or `keystore.properties`.** Passwords stay on the runner, not in Gitea Actions secrets.
Deploy Prod expects these host files (separate from RealPath’s `keystore.properties`, so the two apps do not share one symlink target):
```text
/home/westfarn/.android/hesychia-upload.jks
/home/westfarn/.android/hesychia-keystore.properties # mode 600
```
Create the upload key **once**. If Play already has an upload key for `ai.hesychia.chat`, reuse that keystore. A second keystore will not upload.
**Never commit keystores.** Store outside the repo (e.g. host secrets / password manager).
```bash
keytool -genkey -v \
-keystore /home/westfarn/.android/hesychia-upload.jks \
keytool -genkey -v -keystore ~/Documents/secrets/aiml-chat-upload.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias aiml-chat-upload
```
`hesychia-keystore.properties` (`storeFile` may be absolute):
Create `llm-fe/android/keystore.properties` (gitignored):
```properties
storeFile=/home/westfarn/.android/hesychia-upload.jks
storeFile=/absolute/path/to/aiml-chat-upload.jks
storePassword=...
keyAlias=aiml-chat-upload
keyPassword=...
```
The workflow symlinks that file to `llm-fe/android/keystore.properties` and writes `android/local.properties` with `sdk.dir=/home/westfarn/Android/Sdk`. For a local release build, put the same `keystore.properties` in `llm-fe/android/` (gitignored).
Also on the runner: JDK 17+ and Android SDK Platform 35 at `/home/westfarn/Android/Sdk`.
Gitea Actions secret on `chat_web_app`: `GITHUB_TOKEN` (repo release write). The workflow maps it to `GITEA_TOKEN` for version lookup and release upload. Purchase verification uses backend `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`, not this keystore.
Release App Bundle:
```bash
@@ -167,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
+9 -20
View File
@@ -19,8 +19,7 @@ From the **npm root** (`llm-fe/`, one level up):
```bash
cd ..
npm ci
npm run build:android:store # Play binary: .env.mobile → build → cap sync android
# npm run build:mobile # same web env, also syncs iOS
npm run build:mobile # .env.mobile → react-scripts build → cap sync android
```
Open this project in Android Studio:
@@ -55,11 +54,11 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
## Version before a store release
`app/build.gradle` stores the floor (`hesychiaVersionNameFloor` / `hesychiaVersionCodeFloor`). A local `bundleRelease` uses that floor. **Deploy Prod** passes the next pair with `-PhesychiaVersionName` and `-PhesychiaVersionCode` after reading `android-v*` Gitea releases, so a prod run does not need a version commit.
Edit `app/build.gradle` → `defaultConfig`:
| Field | Rule |
|-------|------|
| `versionName` | User-visible `major.minor.patch` |
| `versionName` | User-visible string; keep in sync with `../package.json` `version` |
| `versionCode` | Integer; **must increase** for every Play upload |
## Sign a release build
@@ -69,17 +68,15 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
1. Create an upload keystore (once), outside the repo:
```bash
keytool -genkey -v -keystore /home/westfarn/.android/hesychia-upload.jks \
keytool -genkey -v -keystore ~/Documents/secrets/aiml-chat-upload.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias aiml-chat-upload
```
Reuse an existing Play upload key. Do not generate a second keystore for an app that already has one.
2. Create `keystore.properties` in **this** directory (`android/`, gitignored). On the Deploy Prod runner the file is `/home/westfarn/.android/hesychia-keystore.properties` (not RealPath’s `keystore.properties`):
2. Create `keystore.properties` in **this** directory (`android/`, gitignored):
```properties
storeFile=/home/westfarn/.android/hesychia-upload.jks
storeFile=/absolute/path/to/aiml-chat-upload.jks
storePassword=REPLACE_ME
keyAlias=aiml-chat-upload
keyPassword=REPLACE_ME
@@ -108,19 +105,11 @@ Without `keystore.properties`, release builds are unsigned; debug builds do not
## Deploy to Google Play
**Deploy Prod** builds the signed AAB and attaches it to a Gitea release (`android-v<versionName>-<versionCode>`, asset `hesychia-<versionName>-<versionCode>.aab`). Download that asset and upload it yourself. See [`../ANDROID.md`](../ANDROID.md).
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`.
+3 -15
View File
@@ -1,17 +1,5 @@
apply plugin: 'com.android.application'
// Last bundle recorded in git. Prod deploy passes -PhesychiaVersionCode and
// -PhesychiaVersionName; these stay the floor when those properties are absent.
def hesychiaVersionCodeFloor = 1
def hesychiaVersionNameFloor = "0.1.0"
// Resolve before the android block. A parenthesized expression on the versionCode
// setter is parsed as a cast of the setter result, so AGP throws "Value is null".
def hesychiaVersionCodeProp = project.findProperty('hesychiaVersionCode')?.toString()?.trim()
def hesychiaVersionNameProp = project.findProperty('hesychiaVersionName')?.toString()?.trim()
def resolvedVersionCode = hesychiaVersionCodeProp ? hesychiaVersionCodeProp.toInteger() : hesychiaVersionCodeFloor
def resolvedVersionName = hesychiaVersionNameProp ? hesychiaVersionNameProp : hesychiaVersionNameFloor
def keystorePropertiesFile = rootProject.file('keystore.properties')
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
@@ -25,8 +13,8 @@ android {
applicationId "ai.hesychia.chat"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode resolvedVersionCode
versionName resolvedVersionName
versionCode 1
versionName "0.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -39,7 +27,7 @@ android {
if (keystorePropertiesFile.exists()) {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
@@ -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
+184 -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",
@@ -2507,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"
@@ -2520,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",
@@ -3084,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",
@@ -5253,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",
@@ -5352,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",
@@ -5703,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",
@@ -6095,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": {
@@ -6535,7 +6443,7 @@
"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"
}
@@ -9146,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": {
@@ -10015,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": {
@@ -12510,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",
@@ -12906,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",
@@ -15823,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",
@@ -15909,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",
@@ -16106,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"
@@ -16148,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": {
@@ -16734,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",
@@ -19062,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"
},
@@ -19272,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"
@@ -20719,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"
}
@@ -20986,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",
@@ -21809,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",
@@ -22399,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",
@@ -22443,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"
@@ -22456,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"
@@ -22648,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"
@@ -22895,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": {
@@ -23899,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"
@@ -24044,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"
@@ -24083,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
}
}
}
+6 -7
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,18 +54,17 @@
},
"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",
"build:android:store": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync android",
"android:open": "npx cap open android",
"android:sync": "npx cap sync android",
"ios:open": "npx cap open ios",
"ios:sync": "npx cap sync ios",
"assets:generate": "npx capacitor-assets generate --ios --android",
"test": "NODE_ENV=development react-scripts test",
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false && node --test scripts/stampCanonicals.test.js scripts/sitemap.test.js scripts/android-version.test.mjs",
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false",
"eject": "react-scripts eject"
},
"eslintConfig": {
@@ -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>Vision is on the roadmap, so you can ask questions about an image you upload.</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 and vision are on the roadmap.
## 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>
-142
View File
@@ -1,142 +0,0 @@
import { appendFileSync, readFileSync, realpathSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const ANDROID_TAG = /^android-v(\d+\.\d+\.\d+)-(\d+)$/;
export function parseAndroidTag(tag) {
const match = ANDROID_TAG.exec(tag || '');
if (!match) return null;
return { versionName: match[1], versionCode: Number(match[2]) };
}
export function bumpPatch(versionName) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(versionName);
if (!match) {
throw new Error(`versionName must be major.minor.patch, got ${versionName}`);
}
return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
}
export function readBaseline(gradle) {
const versionCode = Number(gradle.match(/hesychiaVersionCodeFloor\s*=\s*(\d+)/)?.[1]);
const versionName = gradle.match(/hesychiaVersionNameFloor\s*=\s*"([^"]+)"/)?.[1];
if (!Number.isInteger(versionCode) || !versionName) {
throw new Error(
'Could not read hesychiaVersionCodeFloor/hesychiaVersionNameFloor from build.gradle',
);
}
return { versionCode, versionName };
}
export function nextAndroidVersion({ baseline, releases, sha }) {
const existing = releases.find((release) => release.sha === sha);
if (existing) {
return {
versionName: existing.versionName,
versionCode: existing.versionCode,
reused: true,
};
}
const highest = releases.reduce((best, release) => {
if (!best || release.versionCode > best.versionCode) return release;
return best;
}, null);
const codeBase = Math.max(baseline.versionCode, highest?.versionCode ?? baseline.versionCode);
const nameBase =
highest && highest.versionCode >= baseline.versionCode
? highest.versionName
: baseline.versionName;
return {
versionName: bumpPatch(nameBase),
versionCode: codeBase + 1,
reused: false,
};
}
export async function listAndroidReleases({ apiUrl, token, owner, repo, fetchImpl = fetch }) {
const releases = [];
for (let page = 1; page <= 20; page += 1) {
const response = await fetchImpl(
`${apiUrl}/repos/${owner}/${repo}/releases?limit=50&page=${page}`,
{
headers: {
Authorization: `token ${token}`,
Accept: 'application/json',
},
},
);
if (!response.ok) {
throw new Error(`List releases failed (${response.status})`);
}
const payload = await response.json();
const batch = Array.isArray(payload) ? payload : [];
for (const release of batch) {
const parsed = parseAndroidTag(release.tag_name);
if (!parsed) continue;
releases.push({ ...parsed, sha: release.target_commitish });
}
if (batch.length < 50) break;
}
return releases;
}
function releaseEnv() {
const token = process.env.GITEA_TOKEN || process.env.GITHUB_TOKEN;
const repository = process.env.GITHUB_REPOSITORY || process.env.GITEA_REPOSITORY;
const sha = process.env.GITEA_SHA || process.env.GITHUB_SHA;
const apiUrl = (
process.env.GITHUB_API_URL ||
process.env.GITEA_API_URL ||
(process.env.GITHUB_SERVER_URL
? `${process.env.GITHUB_SERVER_URL.replace(/\/$/, '')}/api/v1`
: '')
).replace(/\/$/, '');
if (!token || !repository || !sha || !apiUrl) {
throw new Error('Missing Gitea token, repository, sha, or API URL for the Play version lookup');
}
const [owner, repo] = repository.split('/');
if (!owner || !repo) {
throw new Error(`Repository must be owner/repo, got ${repository}`);
}
return { token, owner, repo, sha, apiUrl };
}
export async function resolveAndroidVersion() {
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const gradle = readFileSync(join(root, 'android', 'app', 'build.gradle'), 'utf8');
const baseline = readBaseline(gradle);
const { token, owner, repo, sha, apiUrl } = releaseEnv();
const releases = await listAndroidReleases({ apiUrl, token, owner, repo });
return nextAndroidVersion({ baseline, releases, sha });
}
function isMainModule() {
const entry = process.argv[1];
if (!entry) return false;
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
}
if (isMainModule()) {
resolveAndroidVersion()
.then((next) => {
const lines = [
`HESYCHIA_VERSION_NAME=${next.versionName}`,
`HESYCHIA_VERSION_CODE=${next.versionCode}`,
];
if (process.env.GITHUB_ENV) {
appendFileSync(process.env.GITHUB_ENV, `${lines.join('\n')}\n`);
}
console.log(lines.join('\n'));
const action = next.reused ? 'Reusing' : 'Next Play version is';
console.error(`${action} ${next.versionName} (${next.versionCode})`);
})
.catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
}
-113
View File
@@ -1,113 +0,0 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
bumpPatch,
listAndroidReleases,
nextAndroidVersion,
parseAndroidTag,
readBaseline,
} from './android-version.mjs';
const gradle = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), '../android/app/build.gradle'),
'utf8',
);
const floor = { versionName: '0.1.0', versionCode: 1 };
describe('android version', () => {
it('reads the floor still written in build.gradle', () => {
assert.deepEqual(readBaseline(gradle), floor);
});
it('assigns versionCode from a resolved int, not a parenthesized cast', () => {
assert.match(gradle, /versionCode resolvedVersionCode/);
assert.doesNotMatch(gradle, /versionCode\s*\(/);
});
it('parses Play release tags and ignores other tags', () => {
assert.deepEqual(parseAndroidTag('android-v1.0.2-3'), {
versionName: '1.0.2',
versionCode: 3,
});
assert.equal(parseAndroidTag('web-v1.0.2'), null);
});
it('bumps the patch', () => {
assert.equal(bumpPatch('0.1.0'), '0.1.1');
assert.equal(bumpPatch('1.0.9'), '1.0.10');
});
it('steps past the floor when nothing has been released yet', () => {
assert.deepEqual(
nextAndroidVersion({ baseline: floor, releases: [], sha: 'abc' }),
{
versionName: '0.1.1',
versionCode: 2,
reused: false,
},
);
});
it('reuses the release that already points at this commit', () => {
assert.deepEqual(
nextAndroidVersion({
baseline: floor,
sha: 'abc',
releases: [{ versionName: '0.1.1', versionCode: 2, sha: 'abc' }],
}),
{ versionName: '0.1.1', versionCode: 2, reused: true },
);
});
it('bumps from the highest release rather than the gradle floor', () => {
assert.deepEqual(
nextAndroidVersion({
baseline: floor,
sha: 'def',
releases: [
{ versionName: '0.1.1', versionCode: 2, sha: 'abc' },
{ versionName: '0.1.4', versionCode: 5, sha: 'old' },
],
}),
{ versionName: '0.1.5', versionCode: 6, reused: false },
);
});
it('keeps a hand-raised floor ahead of older releases', () => {
assert.deepEqual(
nextAndroidVersion({
baseline: { versionName: '1.1.0', versionCode: 10 },
sha: 'def',
releases: [{ versionName: '0.1.4', versionCode: 5, sha: 'old' }],
}),
{ versionName: '1.1.1', versionCode: 11, reused: false },
);
});
it('lists only android release tags', async () => {
const fetchImpl = async () => ({
ok: true,
status: 200,
json: async () => [
{ tag_name: 'android-v0.1.1-2', target_commitish: 'abc' },
{ tag_name: 'v0.1.1', target_commitish: 'nope' },
],
});
await assert.doesNotReject(
listAndroidReleases({
apiUrl: 'https://git.example/api/v1',
token: 'token',
owner: 'ai_ml_operations',
repo: 'chat_web_app',
fetchImpl,
}).then((releases) => {
assert.deepEqual(releases, [
{ versionName: '0.1.1', versionCode: 2, sha: 'abc' },
]);
}),
);
});
});
-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);
});
}
@@ -1,152 +0,0 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const gradlePath = join(root, 'android', 'app', 'build.gradle');
const aabPath = join(root, 'android', 'app', 'build', 'outputs', 'bundle', 'release', 'app-release.aab');
const gradle = readFileSync(gradlePath, 'utf8');
const versionCode =
process.env.HESYCHIA_VERSION_CODE ||
gradle.match(/hesychiaVersionCodeFloor\s*=\s*(\d+)/)?.[1];
const versionName =
process.env.HESYCHIA_VERSION_NAME ||
gradle.match(/hesychiaVersionNameFloor\s*=\s*"([^"]+)"/)?.[1];
if (!versionCode || !versionName) {
console.error(
`Could not read versionCode/versionName from the environment or ${gradlePath}`,
);
process.exit(1);
}
const tag = `android-v${versionName}-${versionCode}`;
const assetName = `hesychia-${versionName}-${versionCode}.aab`;
const token = process.env.GITEA_TOKEN || process.env.GITHUB_TOKEN;
const repository = process.env.GITHUB_REPOSITORY || process.env.GITEA_REPOSITORY;
const sha = process.env.GITEA_SHA || process.env.GITHUB_SHA;
const refName = process.env.GITEA_REF_NAME || process.env.GITHUB_REF_NAME || sha;
const apiUrl = (
process.env.GITHUB_API_URL ||
process.env.GITEA_API_URL ||
(process.env.GITHUB_SERVER_URL
? `${process.env.GITHUB_SERVER_URL.replace(/\/$/, '')}/api/v1`
: '')
).replace(/\/$/, '');
if (!token || !repository || !sha || !apiUrl) {
console.error('Missing required env for Gitea Android release:', {
apiUrl: Boolean(apiUrl),
token: Boolean(token),
repository,
sha,
});
process.exit(1);
}
const [owner, repo] = repository.split('/');
if (!owner || !repo) {
console.error(`GITHUB_REPOSITORY must be owner/repo, got ${repository}`);
process.exit(1);
}
async function api(path, { method = 'GET', json, form } = {}) {
const headers = {
Authorization: `token ${token}`,
Accept: 'application/json',
};
let body;
if (json !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(json);
} else if (form) {
body = form;
}
const response = await fetch(`${apiUrl}/repos/${owner}/${repo}${path}`, {
method,
headers,
body,
});
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
}
return { status: response.status, payload };
}
function releaseUrl(release) {
return release?.html_url || release?.url || tag;
}
function hasAsset(release) {
return Array.isArray(release?.assets) && release.assets.some((asset) => asset.name === assetName);
}
async function uploadAsset(releaseId) {
const bytes = readFileSync(aabPath);
const form = new FormData();
form.append('attachment', new Blob([bytes]), assetName);
const { status, payload } = await api(
`/releases/${releaseId}/assets?name=${encodeURIComponent(assetName)}`,
{ method: 'POST', form },
);
if (status < 200 || status >= 300) {
throw new Error(`Asset upload failed (${status}): ${JSON.stringify(payload)}`);
}
}
const body = [
`Play store bundle for the prod deploy of \`${refName}\`.`,
'',
`- Commit: \`${sha}\``,
`- versionName: \`${versionName}\``,
`- versionCode: \`${versionCode}\``,
'- Application id: `ai.hesychia.chat`',
'',
`Upload \`${assetName}\` to Play Console.`,
].join('\n');
const existing = await api(`/releases/tags/${encodeURIComponent(tag)}`);
let release = existing.payload;
if (existing.status === 404) {
const created = await api('/releases', {
method: 'POST',
json: {
tag_name: tag,
target_commitish: sha,
name: `Android ${versionName} (${versionCode})`,
body,
draft: false,
prerelease: false,
},
});
if (created.status < 200 || created.status >= 300) {
throw new Error(`Create release failed (${created.status}): ${JSON.stringify(created.payload)}`);
}
release = created.payload;
console.log(`Created ${tag} at ${releaseUrl(release)}`);
} else if (existing.status < 200 || existing.status >= 300) {
throw new Error(`Lookup ${tag} failed (${existing.status}): ${JSON.stringify(existing.payload)}`);
} else if (release.target_commitish !== sha) {
console.error(
`${tag} already points at ${release.target_commitish}, not ${sha}. Refusing to replace that Play version.`,
);
process.exit(1);
} else {
console.log(`${tag} already exists for ${sha}`);
}
if (hasAsset(release)) {
console.log(`${assetName} already attached: ${releaseUrl(release)}`);
} else {
await uploadAsset(release.id);
console.log(`Attached ${assetName} to ${releaseUrl(release)}`);
}
-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,12 +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;
};
const MyPlot = ({ format, image }: { format: string; image: string }) => {
@@ -202,12 +220,9 @@ const ConversationDetailCard = ({
createdTimestamp = null,
initialRating = null,
onRatingChange,
activityStage = null,
activityLabel = null,
activityDetail = null,
activityHistory = [],
activityInterrupted = false,
}: ConversationDetailCardProps): JSX.Element => {
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
const CitationMark = useCallback(
({ indices }: { indices?: string }) => {
const list = (indices || '')
@@ -226,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}]
@@ -251,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>
);
@@ -340,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 =
'This will use previous conversations to better customize the experience.';
function prefsErrorMessage(error: unknown, fallback: string): string {
const axiosError = error as {
response?: { data?: { detail?: string } };
message?: string;
};
return axiosError.response?.data?.detail || axiosError.message || fallback;
}
type PreferencesValues = {
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>(false);
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>
Use previous conversations
<Tooltip title={CONTEXT_TOOLTIP}>
<span>
<InfoIcon aria-label={CONTEXT_TOOLTIP} />
</span>
</Tooltip>
</SettingLabel>
<ToggleSwitch>
<Checkbox
type="checkbox"
checked={useConversationContext}
disabled={prefsLoading || contextSaving}
onChange={() => {
handleConversationContext(!useConversationContext);
}}
/>
<Slider />
</ToggleSwitch>
</SettingRow>
{prefsError ? <ErrorText role="alert">{prefsError}</ErrorText> : null}
</GlassCard>
);
};
@@ -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,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');
});
});
+6 -76
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,12 +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 { parseCitationsData, parseVersionedFrame } from "../utils/wsFrames";
type MessageProviderProps ={
children? : ReactNode;
@@ -26,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 = {
@@ -42,11 +31,6 @@ const initialValues = {
isGeneratingMessage: false,
streamInterrupted: false,
clearStreamInterrupted: () => {},
activityStage: null,
activityLabel: null,
activityDetail: null,
activityHistory: [],
clearActivity: () => {},
}
const MessageContext = createContext<IMessageContext>(initialValues);
@@ -63,10 +47,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
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);
@@ -75,19 +55,9 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
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([])
}
async function GetConversationDetails(conversationId: number | undefined) {
if (!conversationId) {
setConversationDetails([])
@@ -128,7 +98,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
}
}
const schedulePostStreamRefetch = useCallback(() => {
const schedulePostStreamRefetch = () => {
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
const conversationId = selectedConversationRef.current
if (!conversationId) return
@@ -137,7 +107,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
refetchTimerRef.current = setTimeout(() => {
void GetConversationDetails(conversationId)
}, 800)
}, [])
}
useEffect(() => {
GetConversationDetails(selectedConversation)
@@ -172,7 +142,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
setIsGeneratingMessage(false)
setStateMessage('')
setStreamInterrupted(true)
clearActivity()
const details = [...conversationRef.current]
if (details.length > 0) {
@@ -218,41 +187,8 @@ 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
}
@@ -272,7 +208,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
isGeneratingRef.current = false
setIsGeneratingMessage(false)
setStreamInterrupted(false)
clearActivity()
schedulePostStreamRefetch()
}
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
@@ -316,7 +251,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
/* unsubscribe from channel during cleanup */
unsubscribe(channelName)
}
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation, schedulePostStreamRefetch])
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation])
return(
@@ -328,11 +263,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
isGeneratingMessage,
streamInterrupted,
clearStreamInterrupted,
activityStage,
activityLabel,
activityDetail,
activityHistory,
clearActivity,
}}>
{children}
</MessageContext.Provider>
-1
View File
@@ -269,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;
@@ -252,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;
@@ -317,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 => {
@@ -346,11 +370,6 @@ const AsyncDashboardInner = (): JSX.Element => {
setConversationDetails,
stateMessage,
streamInterrupted,
activityStage,
activityLabel,
activityDetail,
activityHistory,
clearActivity,
} = useContext(MessageContext);
const conversationRef = useRef(conversationDetails);
@@ -423,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();
@@ -431,7 +450,6 @@ const AsyncDashboardInner = (): JSX.Element => {
return;
}
try {
clearActivity();
const tempConversations: ConversationPrompt[] = [
...conversationDetails,
new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
@@ -440,7 +458,7 @@ const AsyncDashboardInner = (): JSX.Element => {
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),
@@ -450,6 +468,7 @@ const AsyncDashboardInner = (): JSX.Element => {
prompt: "",
file: null,
fileType: null,
modelName: modelName, // Keep the selected model
}
});
@@ -550,6 +569,7 @@ const AsyncDashboardInner = (): JSX.Element => {
{(
[
['pdf', 'PDF'],
['docx', 'Word (.docx)'],
['csv', 'CSV'],
['xlsx', 'Excel (.xlsx)'],
['txt', 'Plain text'],
@@ -586,11 +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}
/>
);
})
@@ -615,6 +630,7 @@ const AsyncDashboardInner = (): JSX.Element => {
prompt: "",
file: null,
fileType: null,
modelName: "FAST",
}}
validationSchema={validationSchema}
onSubmit={handlePromptSubmit}
@@ -665,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
@@ -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(/Vision is on the roadmap/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;
@@ -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',
});
+1 -3
View File
@@ -10,7 +10,6 @@ import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import { trackHeyCatch } from '../../utils/heycatch';
import { checkoutReturnUrls } from '../../utils/finance';
import { isNativePlatform } from '../../platform/nativePlatform';
import { logInRevenueCat, purchasePlan } from '../../utils/revenueCat';
@@ -246,7 +245,6 @@ const SignUp = (): JSX.Element => {
const account = await loadAccount();
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' });
identifyAccount(account);
trackHeyCatch('signup_completed');
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
console.warn('RevenueCat logIn', err)
);
@@ -273,7 +271,7 @@ const SignUp = (): JSX.Element => {
const { success_url, cancel_url } = checkoutReturnUrls();
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' });
const checkoutResponse = await axiosInstance.post('/monetization/checkout/', {
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
success_url,
cancel_url,
});
@@ -6,7 +6,6 @@ import { AuthContext } from '../../contexts/AuthContext';
import { AccountContext } from '../../contexts/AccountContext';
import { Account } from '../../data';
import TermsOfServiceDocument from '../../components/legal/TermsOfServiceDocument';
import RelatedPages from '../../components/PublicSite/RelatedPages';
import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
@@ -104,17 +103,13 @@ const TermsOfService = ({ publicView = false }: TermsOfServiceProps): JSX.Elemen
return (
<Box sx={{ py: 4, px: 2, minHeight: '100vh', bgcolor: 'background.default' }}>
{documentBody}
<RelatedPages />
</Box>
);
}
return (
<PageWrapperLayout>
<Box sx={{ margin: '0 auto', width: { xs: '95%', md: '80%' }, py: 2 }}>
{documentBody}
<RelatedPages />
</Box>
<Box sx={{ margin: '0 auto', width: { xs: '95%', md: '80%' }, py: 2 }}>{documentBody}</Box>
</PageWrapperLayout>
);
};
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more