Author SHA1 Message Date
westfarn 0d233b077c Publish a signed Play AAB from Deploy Prod.
Unit Tests / test (pull_request) Successful in 14s
Closes #121. Supersedes the separate Deploy Android workflow in #58 by attaching hesychia-<version>-<code>.aab to an android-v* Gitea release after the prod web deploy.
2026-10-19 10:26:36 -05:00
westfarn 36341f6e72 Product page: lifetime price, rounded usage, roadmap (#126)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 13s
Deploy Beta / deploy-beta (push) Successful in 3m23s
## Summary
- Closes #123.
- Hide account and document counts until there are 20 accounts. After that, both figures round down to one significant digit and show a plus, so the page never publishes an exact count.
- The product page and pricing page say early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.
- The product page adds a roadmap section for image generation and vision.

## Test plan
- [ ] With fewer than 20 accounts, the product page shows no usage figures.
- [ ] With 23 accounts and 156 documents, the page shows 20+ accounts and 100+ documents stored, plus the as-of date.
- [ ] The product page states the lifetime $10 lock and lists image generation and vision under Roadmap.
- [ ] The pricing page states the same lifetime lock.

Reviewed-on: #126
2026-10-19 08:14:49 -07:00
westfarn 7a8f413483 Public page SEO and production analytics (#125)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 13s
Deploy Beta / deploy-beta (push) Successful in 3m50s
## Summary
- Closes the public-page gaps from #124: each route gets its own title, description, canonical, heading, links, and JSON-LD, and unknown URLs have a real 404 document.
- Adds HeyCatch and the Google tag (`G-V12DLRLPWR`) only on the production site and the iOS and Android apps. Tianji still loads on beta.
- Needs the server-infra change that stops serving the app shell for missing paths.

## Test plan
- [ ] Build production and confirm `/` and `/pricing/` HTML include a unique title, description, canonical, one h1, social tags, related links, and Organization JSON-LD.
- [ ] Confirm a nonsense path is served as `404.html` once the nginx change is deployed.
- [ ] Confirm beta and local web do not load HeyCatch or gtag, and that a production or native build does.
- [ ] Open the live site after deploy and click around, then mark HeyCatch installed in its dashboard.

Reviewed-on: #125
2026-10-19 08:01:44 -07:00
westfarn 8b58192026 Buy on StoreKit and Play Billing, then confirm with the backend (#78) (#122)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 3m9s
## Summary
- The Capacitor shell buys through StoreKit (iOS 15) and Play Billing instead of RevenueCat.
- After the store sheet succeeds, the app POSTs the signed transaction or Play purchase token to `/monetization/store/confirm/`.
- Restore reads current store entitlements and confirms each one. Web checkout stays on Stripe.
- The store account token from `GET /monetization/subscription/` is sent as `appAccountToken` / `obfuscatedAccountId`.

Depends on the `chat_backend` PR for issue #78 (`feat/78-native-store-purchases`).

## Test plan
- [x] `storeBilling`, `revenueCat`, `finance`, and `BillingSection` unit tests
- [ ] iOS sandbox purchase and restore against a backend with `APPLE_IAP_BUNDLE_ID` set
- [ ] Play license-test purchase and restore against a backend with `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` set
- [ ] Web billing still opens Stripe Checkout

Reviewed-on: #122
2026-10-19 04:38:43 -07:00
westfarn 4a5b05f421 Logged-out home stays the product page (#120)
Unit Tests / test (push) Successful in 12s
Deploy Beta / unit-tests (push) Successful in 13s
Deploy Beta / deploy-beta (push) Successful in 4m8s
## Summary
- Closes #118 and #119.
- Logged-out `/` is the product page. Sign In is a link. After sign-in, `/` is still the chat dashboard.
- A 401 from a public API call (for example a stale token on `/public/usage/`) no longer sends visitors from `/` to Sign In. Protected routes such as `/account/` still redirect.
- Product, pricing, changelog, and privacy use the same floating-dot background as Sign In and the dashboard. Text follows the light and dark theme so it stays readable.

## Test plan
- [x] `authRedirect` tests: public API calls and public paths do not redirect; `/account/` and `/document_storage` still do.
- [x] Landing, pricing, privacy, changelog, HomeRoute, and Sign In tests pass.
- [ ] Logged out, open `/` and confirm the product page, then Sign In, then `/` is the dashboard.
- [ ] With an expired access token still in storage, open `/` and confirm you stay on the product page.
- [ ] Check product, pricing, changelog, and privacy in light and dark mode. Dots visible, text readable.

Reviewed-on: #120
2026-10-19 01:09:43 -07:00
westfarn fa91c4969f Public homepage, pricing, privacy, changelog, and SEO (#117)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 5m0s
## Summary
- Logged-out `/` is a public landing page. Signed-in `/` still opens chat.
- Adds `/pricing/`, `/privacy/`, and `/changelog/`, plus header and footer links.
- Primary path is **Create account** when registration is on, and an email **waitlist** when it is off.
- Titles, canonicals, Organization and Product JSON-LD, `sitemap.xml`, `robots.txt`, and `llms.txt`.
- Usage counts render only from `GET /api/public/usage/`. No avatars. No founder block. No testimonials.
- Closes #113, #114, #115, and #116.

Needs [chat_backend#75](https://git.aimloperations.com/ai_ml_operations/chat_backend/pulls) for the waitlist and usage API, and the server-infra crawler-file change so those root files are not the SPA shell.

The account and document numbers in the screenshots come from a local mock of the usage API, not production.

## Screenshots

### Landing, registration off
![Landing waitlist desktop](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/landing-desktop.png)

### Landing, mobile
![Landing mobile](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/landing-mobile.png)

### Landing, registration on
![Landing create account](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/landing-signup.png)

### Pricing
![Pricing](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/pricing.png)

### Privacy
![Privacy](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/privacy.png)

### Changelog
![Changelog](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/changelog.png)

### Sign in unchanged
![Sign in](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/signin.png)

## Test plan
- [x] `react-scripts test` — 234 passed
- [ ] Incognito `/` shows the landing page, not Sign In
- [ ] Signed-in `/` still opens chat
- [ ] `/account/` still redirects to `/signin/` when logged out
- [ ] Waitlist POST and usage GET against the new API
- [ ] `/robots.txt`, `/sitemap.xml`, and `/llms.txt` return plain text, not HTML

Reviewed-on: #117
2026-10-17 08:15:02 -07:00
westfarn 1a434b208e Update SignIn tests for registration-gated SSO buttons.
Unit Tests / test (push) Successful in 12s
Deploy Beta / unit-tests (push) Successful in 38s
Deploy Beta / deploy-beta (push) Successful in 3m48s
2026-08-05 09:02:58 -05:00
westfarn 508f11b558 Hide the sso buttons if registration is not allowed
Deploy Beta / unit-tests (push) Failing after 13s
Deploy Beta / deploy-beta (push) Skipped
Unit Tests / test (push) Failing after 12s
2026-08-05 08:59:01 -05:00
westfarn 9e1449f0db Add public account-deletion page for Google Play (#103) (#110)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m23s
## Summary
- Closes [#103](#103).
- Adds public SPA route `/account-deletion/` (no auth) with Hesychia / `ai.hesychia.chat` branding, in-app deletion steps, soft-delete data retention copy, and a contact path (30-day SLA) for users who cannot sign in.
- Documents Play Console **Account deletion URL** in `ANDROID.md` and `android/README.md` (`https://hesychia.ai/account-deletion/`, legacy host, HashRouter form).

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

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

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

## Test plan
- [ ] Open chat dashboard — composer shows attach + text field + send only (no FAST/THINKING dropdown).
- [ ] Send a message — stream still works.
- [ ] Send with attachment — still works.Reviewed-on: #107
2026-08-04 10:17:32 -07:00
103 changed files with 4793 additions and 294 deletions

No files matched your search

+58
View File
@@ -36,3 +36,61 @@ 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
+64 -21
View File
@@ -40,28 +40,47 @@ 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`.
### RevenueCat IAP (#100)
### Play Billing (#78)
Native billing uses `@revenuecat/purchases-capacitor` (Capacitor 7 → package **11.x**). Web keeps Stripe Checkout / Customer Portal.
Native billing uses the local Capacitor plugin `native-store-billing` (Play Billing on Android, StoreKit on iOS). Web keeps Stripe Checkout / Customer Portal.
Set public SDK keys in `.env.mobile` (empty placeholders OK until store apps ship):
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).
| 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).
After plugin changes: `npm run build:mobile` (runs `cap sync`).
## Versioning
In `android/app/build.gradle`:
`android/app/build.gradle` keeps a floor:
- **versionName** — user-visible string; keep aligned with `package.json` `version` (currently `0.1.0`)
- **versionCode** — integer; **must increase** for every Play Store upload
- `hesychiaVersionNameFloor` — `"0.1.0"` (user-visible string)
- `hesychiaVersionCodeFloor` — `1` (integer; Play requires a higher code on every upload)
Bump both before each store release.
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
```
## Icons / splash
@@ -82,23 +101,39 @@ Same command also regenerates iOS `Assets.xcassets` (see [`IOS.md`](IOS.md)).
## Signing (upload keystore)
**Never commit keystores.** Store outside the repo (e.g. host secrets / password manager).
**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.
```bash
keytool -genkey -v -keystore ~/Documents/secrets/aiml-chat-upload.jks \
keytool -genkey -v \
-keystore /home/westfarn/.android/hesychia-upload.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias aiml-chat-upload
```
Create `llm-fe/android/keystore.properties` (gitignored):
`hesychia-keystore.properties` (`storeFile` may be absolute):
```properties
storeFile=/absolute/path/to/aiml-chat-upload.jks
storeFile=/home/westfarn/.android/hesychia-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
@@ -132,12 +167,20 @@ Out of band ops (not automated here):
1. Google Play developer account
2. Privacy policy URL (product site / legal)
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
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).
## 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
+8 -25
View File
@@ -1,40 +1,23 @@
# Monetization (web Stripe + native RevenueCat)
# Monetization (web Stripe + native store billing)
## Channels
| Runtime | Checkout / manage | Ledger |
|---------|-------------------|--------|
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/monetization/...` | Stripe webhooks |
| Native Capacitor | RevenueCat IAP (`@revenuecat/purchases-capacitor`) | RevenueCat webhooks → same invoice/payment tables |
| Native Capacitor | StoreKit (iOS) and Play Billing (Android) | `POST /monetization/store/confirm/` verifies the store transaction and writes the same invoice/payment tables |
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`.
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.
## App user id
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` (`app_store`, `play_store`, or legacy `revenuecat`) plus `revenuecat_store`.
RevenueCat `appUserID` = Django user pk as string.
## Store account token
1. Prefer `id` from `/user/get/` (serializer returns all model fields).
2. Else JWT `user_id` claim (`SIMPLE_JWT.USER_ID_CLAIM`).
`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.
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.
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.
## Related
- Issue #100 (FE) + companion backend RevenueCat webhook PR
- Backend issue #78
- [`ANDROID.md`](ANDROID.md) / [`IOS.md`](IOS.md) for store builds
+20 -9
View File
@@ -19,7 +19,8 @@ From the **npm root** (`llm-fe/`, one level up):
```bash
cd ..
npm ci
npm run build:mobile # .env.mobile → react-scripts build → cap sync android
npm run build:android:store # Play binary: .env.mobile → build → cap sync android
# npm run build:mobile # same web env, also syncs iOS
```
Open this project in Android Studio:
@@ -54,11 +55,11 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
## Version before a store release
Edit `app/build.gradle` → `defaultConfig`:
`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.
| Field | Rule |
|-------|------|
| `versionName` | User-visible string; keep in sync with `../package.json` `version` |
| `versionName` | User-visible `major.minor.patch` |
| `versionCode` | Integer; **must increase** for every Play upload |
## Sign a release build
@@ -68,15 +69,17 @@ Edit `app/build.gradle` → `defaultConfig`:
1. Create an upload keystore (once), outside the repo:
```bash
keytool -genkey -v -keystore ~/Documents/secrets/aiml-chat-upload.jks \
keytool -genkey -v -keystore /home/westfarn/.android/hesychia-upload.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias aiml-chat-upload
```
2. Create `keystore.properties` in **this** directory (`android/`, gitignored):
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`):
```properties
storeFile=/absolute/path/to/aiml-chat-upload.jks
storeFile=/home/westfarn/.android/hesychia-upload.jks
storePassword=REPLACE_ME
keyAlias=aiml-chat-upload
keyPassword=REPLACE_ME
@@ -105,11 +108,19 @@ 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. **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.
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).
Each new upload needs a higher `versionCode`.
+15 -3
View File
@@ -1,5 +1,17 @@
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()) {
@@ -13,8 +25,8 @@ android {
applicationId "ai.hesychia.chat"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "0.1.0"
versionCode resolvedVersionCode
versionName resolvedVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -27,7 +39,7 @@ android {
if (keystorePropertiesFile.exists()) {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
@@ -13,6 +13,7 @@ dependencies {
implementation project(':capacitor-keyboard')
implementation project(':capacitor-preferences')
implementation project(':capacitor-status-bar')
implementation project(':native-store-billing')
}
+3
View File
@@ -13,3 +13,6 @@ 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.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

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 = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.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 = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.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 = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.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 = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = ai.hesychia.chat;
+2 -1
View File
@@ -1,6 +1,6 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0'
platform :ios, '15.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
@@ -15,6 +15,7 @@ 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
+124 -19
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",
@@ -39,6 +39,7 @@
"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",
@@ -3083,6 +3084,103 @@
"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",
@@ -5155,6 +5253,21 @@
"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",
@@ -5239,24 +5352,6 @@
"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",
@@ -16639,6 +16734,10 @@
"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",
@@ -23984,6 +24083,12 @@
"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",
+7 -5
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",
@@ -34,6 +34,7 @@
"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",
@@ -53,17 +54,18 @@
},
"scripts": {
"start": "NODE_ENV=development react-scripts start",
"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": "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: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",
"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",
"eject": "react-scripts eject"
},
"eslintConfig": {
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,30 @@
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"
}
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,243 @@
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
@@ -0,0 +1 @@
export {};
+2
View File
@@ -0,0 +1,2 @@
// Native code is loaded by Capacitor. The web app registers the plugin from src.
module.exports = {};
@@ -0,0 +1,94 @@
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
}
}
@@ -0,0 +1,26 @@
{
"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
@@ -0,0 +1,48 @@
<!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>
@@ -0,0 +1 @@
8d1667dd71c6ef137171989c26eeb632
+57 -6
View File
@@ -8,11 +8,62 @@
<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 — a calm AI assistant by AI ML Operations, LLC" />
<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="application-name" content="Hesychia" />
<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="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>
<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
@@ -28,11 +79,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</title>
<title>Hesychia — quiet, focused AI chat</title>
</head>
<body>
<div id="root"></div>
<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>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
+18
View File
@@ -0,0 +1,18 @@
# 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.

After

Width:  |  Height:  |  Size: 212 KiB

+14 -1
View File
@@ -1,3 +1,16 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
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
+27
View File
@@ -0,0 +1,27 @@
<?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
@@ -0,0 +1,142 @@
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
@@ -0,0 +1,113 @@
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
@@ -0,0 +1,40 @@
/**
* 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);
});
}
@@ -0,0 +1,152 @@
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
@@ -0,0 +1,153 @@
/**
* 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
@@ -0,0 +1,87 @@
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
@@ -0,0 +1,175 @@
/**
* 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
@@ -0,0 +1,134 @@
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
@@ -0,0 +1,3 @@
const { writeSitemap } = require('./sitemap');
writeSitemap();
+13 -2
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,6 +25,11 @@ 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);
@@ -51,6 +56,7 @@ class App extends Component {
<AnalyticsSession />
<ToastHost />
<AnalyticsConsentBanner />
<RouteMeta />
<div className='site'>
<main>
<div className="main-container">
@@ -71,9 +77,14 @@ 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} />
+14 -5
View File
@@ -11,6 +11,7 @@ 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;
@@ -97,8 +98,10 @@ axiosInstance.interceptors.response.use(
if (error.response.status === 401 && isRefreshCall) {
await clearTokens();
applyAccessToken(null);
// Hash-safe on Capacitor (#24); plain path on web BrowserRouter.
redirectToAppPath("/signin/");
// Stay on the product page. Only bounce protected app routes.
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
return Promise.reject(error);
}
@@ -109,7 +112,9 @@ axiosInstance.interceptors.response.use(
if (!refresh_token) {
await clearTokens();
applyAccessToken(null);
redirectToAppPath("/signin/");
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
return Promise.reject(error);
}
@@ -127,14 +132,18 @@ axiosInstance.interceptors.response.use(
} catch (err) {
await clearTokens();
applyAccessToken(null);
redirectToAppPath("/signin/");
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
return Promise.reject(err);
}
}
await clearTokens();
applyAccessToken(null);
redirectToAppPath("/signin/");
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
}
return Promise.reject(error);
+1
View File
@@ -1,3 +1,4 @@
import './llm-fe/utils/heycatch';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,38 @@
/** 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;
}
@@ -3,6 +3,7 @@ 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.
@@ -15,7 +16,18 @@ const AnalyticsSession = (): null => {
const wasAuthenticatedRef = useRef<boolean | null>(null);
useEffect(() => {
if (!hasConsent || loading) return;
if (loading) return;
if (account) {
identifyHeyCatch(account);
} else if (wasAuthenticatedRef.current && !authenticated) {
resetHeyCatch();
}
if (!hasConsent) {
wasAuthenticatedRef.current = authenticated;
return;
}
if (account) {
identifyAccount(account);
@@ -367,7 +367,9 @@ const BillingSection = (): JSX.Element => {
date: payment.paid_at || payment.created,
description: 'Payment',
provider:
(payment.provider || '').toLowerCase() === 'revenuecat'
['revenuecat', 'app_store', 'play_store'].includes(
(payment.provider || '').toLowerCase()
)
? 'Store'
: (payment.provider || '').toLowerCase() === 'stripe'
? 'Stripe'
@@ -414,9 +416,7 @@ const BillingSection = (): JSX.Element => {
...(planSlug ? { plan_slug: planSlug } : {}),
});
await purchasePlan(planSlug);
setActionNotice(
'Purchase submitted. Entitlements update after the store confirms — tap Refresh shortly.'
);
setActionNotice('Purchase complete.');
await loadBilling();
} catch (error: unknown) {
if (isPurchaseCancelledError(error)) {
@@ -25,5 +25,8 @@ 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,6 +1,7 @@
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 (
@@ -8,8 +9,12 @@ const Footer = (): JSX.Element => {
<MDTypography>
&copy; 2026 Hesychia | Developed by{' '}
<a href="https://www.aimloperations.com">AI ML Operations, LLC</a>
{' · '}
<RouterLink to="/terms_of_service/">Terms of Service</RouterLink>
{PUBLIC_FOOTER_LINKS.map((link) => (
<React.Fragment key={link.to}>
{' · '}
<RouterLink to={link.to}>{link.label}</RouterLink>
</React.Fragment>
))}
</MDTypography>
</div>
);
@@ -0,0 +1,47 @@
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();
});
});
@@ -0,0 +1,21 @@
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;
@@ -0,0 +1,222 @@
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;
@@ -0,0 +1,136 @@
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;
@@ -0,0 +1,36 @@
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;
@@ -0,0 +1,84 @@
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;
@@ -0,0 +1,17 @@
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';
@@ -0,0 +1,28 @@
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');
});
});
@@ -0,0 +1,34 @@
/** 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}`;
}
@@ -0,0 +1,18 @@
/** 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' },
];
@@ -0,0 +1,19 @@
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;
@@ -1,14 +1,25 @@
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',
@@ -28,6 +39,11 @@ 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', () => {
@@ -41,6 +57,21 @@ 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', () => {
@@ -54,5 +85,6 @@ describe('Tracker', () => {
render(<Tracker />);
expect(document.getElementById(TRACKER_SCRIPT_ID)).toBeNull();
expect(document.getElementById(GTAG_SCRIPT_ID)).toBeNull();
});
});
@@ -1,8 +1,28 @@
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.
@@ -10,29 +30,35 @@ const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
*/
const Tracker: React.FC = () => {
useEffect(() => {
if (!isAnalyticsEnvironment()) return;
if (isProductAnalyticsEnabled()) {
loadGoogleTag();
}
const websiteId = process.env.REACT_APP_TIANJI_WEBSITE_ID || '';
if (!websiteId || document.getElementById(TRACKER_SCRIPT_ID)) return;
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();
};
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);
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);
}
};
}, []);
@@ -0,0 +1,35 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import AccountDeletionPage from './AccountDeletionPage';
describe('AccountDeletionPage', () => {
it('renders Hesychia deletion steps and retention copy without auth', () => {
render(
<MemoryRouter>
<AccountDeletionPage />
</MemoryRouter>,
);
expect(
screen.getByRole('heading', { name: /Hesychia — Request account deletion/i }),
).toBeInTheDocument();
expect(screen.getByText(/ai\.hesychia\.chat/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /How to delete your account/i }),
).toBeInTheDocument();
expect(screen.getByText(/Delete my account/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Deactivated immediately/i }),
).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Data retained after soft-delete/i }),
).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Retention period/i })).toBeInTheDocument();
expect(screen.getByText(/no automatic hard-purge schedule/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Cannot access your account/i }),
).toBeInTheDocument();
expect(screen.getByText(/30 days/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,119 @@
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;
@@ -0,0 +1,27 @@
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);
});
});
@@ -0,0 +1,62 @@
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;
@@ -0,0 +1,31 @@
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,
},
];
@@ -0,0 +1,138 @@
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.");
});
});
@@ -0,0 +1,152 @@
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;
@@ -0,0 +1,32 @@
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();
});
});
@@ -0,0 +1,46 @@
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;
@@ -0,0 +1,27 @@
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/',
);
});
});
@@ -0,0 +1,64 @@
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;
+18 -3
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 oauth providers configured', async () => {
it('shows SSO buttons when registration enabled and oauth providers configured', async () => {
mockGet.mockResolvedValue({
data: {
enable_account_registration: false,
enable_account_registration: true,
oauth: { google: true, microsoft: true },
},
});
@@ -92,10 +92,25 @@ describe('SignIn', () => {
expect(screen.getByRole('button', { name: 'Sign in with Microsoft' })).toBeInTheDocument();
});
it('hides SSO buttons when oauth not configured', async () => {
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,
oauth: { google: false, microsoft: false },
},
});
+8 -6
View File
@@ -264,7 +264,7 @@ const SignIn = (): JSX.Element => {
<ContentWrapper>
<GlassCard>
<BrandBlock>
<BrandMark src={hesychiaMark} alt="" />
<BrandMark src={hesychiaMark} alt="Hesychia logo" />
<BrandWordmark>Hesychia.ai</BrandWordmark>
</BrandBlock>
<CardTitle>Sign In</CardTitle>
@@ -294,11 +294,13 @@ const SignIn = (): JSX.Element => {
<StyledButton type="submit" disabled={formik.isSubmitting}>
Sign In
</StyledButton>
<SsoButtons
intent="login"
providers={oauthProviders}
disabled={formik.isSubmitting}
/>
{registrationEnabled && (
<SsoButtons
intent="login"
providers={oauthProviders}
disabled={formik.isSubmitting}
/>
)}
</Form>
)}
</Formik>
@@ -10,6 +10,7 @@ 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';
@@ -245,6 +246,7 @@ 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)
);
@@ -6,6 +6,7 @@ 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';
@@ -103,13 +104,17 @@ 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}</Box>
<Box sx={{ margin: '0 auto', width: { xs: '95%', md: '80%' }, py: 2 }}>
{documentBody}
<RelatedPages />
</Box>
</PageWrapperLayout>
);
};
@@ -0,0 +1,25 @@
import { registerPlugin } from '@capacitor/core';
export type StoreName = 'app_store' | 'play_store';
export interface NativeStoreTransaction {
store: StoreName;
productId: string;
transactionId: string;
purchaseToken?: string;
signedTransaction?: string;
}
export interface NativeStoreBillingPlugin {
purchase(options: {
productId: string;
accountToken: string;
}): Promise<NativeStoreTransaction>;
restore(options: {
accountToken: string;
}): Promise<{ transactions: NativeStoreTransaction[] }>;
}
export const NativeStoreBilling = registerPlugin<NativeStoreBillingPlugin>(
'NativeStoreBilling'
);
@@ -0,0 +1,92 @@
import fs from 'fs';
import path from 'path';
const publicDir = path.resolve(__dirname, '../../public');
describe('public crawler files', () => {
it('serves robots, sitemap, and llms as static text that names the public pages', () => {
const robots = fs.readFileSync(path.join(publicDir, 'robots.txt'), 'utf8');
const sitemap = fs.readFileSync(path.join(publicDir, 'sitemap.xml'), 'utf8');
const llms = fs.readFileSync(path.join(publicDir, 'llms.txt'), 'utf8');
const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf8');
expect(robots).toContain('Sitemap: https://hesychia.ai/sitemap.xml');
expect(robots).toContain('Disallow: /signin/');
expect(robots).not.toContain('<html');
for (const url of [
'https://hesychia.ai/',
'https://hesychia.ai/pricing/',
'https://hesychia.ai/privacy/',
'https://hesychia.ai/changelog/',
'https://hesychia.ai/terms_of_service/',
]) {
expect(sitemap).toContain(url);
expect(llms).toContain(url);
}
expect(sitemap.startsWith('<?xml')).toBe(true);
expect(llms).toContain('$10/month');
expect(llms).toContain('locked for the lifetime of the account');
expect(llms).toContain('Image generation and vision are on the roadmap');
expect(llms).not.toContain('<html');
expect(html).toContain('<title>Hesychia — quiet, focused AI chat</title>');
expect(html.match(/<h1\b/g)).toHaveLength(1);
expect(html).toContain('<h1>Hesychia is a calm AI assistant.</h1>');
expect(html).toContain('id="crawler-prose"');
expect(html).toContain(
'Quiet, focused AI chat — private by default — is how this Hesychia product is built.',
);
expect(html).toContain(
'content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files."',
);
expect(html).toContain('rel="canonical" href="https://hesychia.ai/"');
expect(html).toContain('property="og:title" content="Hesychia — quiet, focused AI chat"');
expect(html).toContain('property="og:image" content="https://hesychia.ai/og-image.png"');
expect(html).toContain('property="og:image:width" content="1200"');
expect(html).toContain('property="og:image:height" content="630"');
expect(html).toContain('name="twitter:card" content="summary_large_image"');
expect(html).toContain('aria-label="Related pages"');
expect(html).toContain('href="/pricing/">Hesychia pricing — $10/month, no ads');
expect(html).toContain('href="/privacy/">What Hesychia stores and does not sell');
expect(html).toContain('href="/account-deletion/">How to request Hesychia account deletion');
expect(html).toContain(
'Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.',
);
expect(html).toContain(
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
);
expect(html).toContain('Vision is on the roadmap, so you can ask questions about an image you upload.');
expect(html).not.toContain('>here<');
expect(html.match(/application\/ld\+json/g)).toHaveLength(1);
expect(html).toContain('"@type": "WebPage"');
expect(html).toContain('"headline": "Hesychia — quiet, focused AI chat"');
expect(html).toContain('"@type": "Organization"');
expect(html).toContain('"@type": "SoftwareApplication"');
expect(html).toContain('"price": "10.00"');
expect(html).toContain('AI ML Operations, LLC');
});
it('ships a 404 document that is not the app shell', () => {
const notFound = fs.readFileSync(path.join(publicDir, '404.html'), 'utf8');
expect(notFound).toContain('<title>The page is not found — Hesychia</title>');
expect(notFound).toContain('"headline": "The page is not found — Hesychia"');
expect(notFound).toContain('"name": "Hesychia"');
expect(notFound).toContain('"logo": "https://hesychia.ai/apple-touch-icon.png"');
expect(notFound).toContain(
'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."',
);
expect(notFound).toContain('<h1>Not Found</h1>');
expect(notFound).toContain(
'The page is not found, and this address is not a Hesychia page.',
);
expect(notFound).toContain(
'Open the home page to read what the product does, what it costs, and how to start an account.',
);
expect(notFound).not.toContain('id="root"');
expect(notFound).toContain('property="og:image" content="https://hesychia.ai/og-image.png"');
expect(notFound).toContain('name="twitter:card" content="summary_large_image"');
expect(notFound).toContain('href="/">What Hesychia is');
expect(notFound).toContain('aria-label="Related pages"');
});
});
+2
View File
@@ -6,5 +6,7 @@ declare global {
track: (eventName: string, data?: Record<string, unknown>) => void;
identify: (userInfo: Record<string, unknown>) => void;
};
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
}
}
@@ -1,6 +1,12 @@
import { flushAnalyticsQueue, identifyAccount, identifyUser, trackEvent } from './analytics';
import { Account } from '../data';
jest.mock('./heycatch', () => ({
identifyHeyCatch: jest.fn(),
resetHeyCatch: jest.fn(),
trackHeyCatch: jest.fn(),
}));
describe('analytics event helpers', () => {
const originalEnv = process.env;
+8
View File
@@ -1,5 +1,6 @@
import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent';
import type { Account } from '../data';
import { identifyHeyCatch } from './heycatch';
type QueuedCall =
| { type: 'track'; eventName: string; data?: Record<string, unknown> }
@@ -27,6 +28,9 @@ const queue: QueuedCall[] = [];
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
* | Activity Stage Completed | Live activity status stage rolled over (#96) |
* | Landing Primary CTA | Create-account click on the public site |
* | Landing Pricing CTA | See-pricing click on the public site |
* | Waitlist Joined | Email accepted by the public waitlist |
*/
export const AnalyticsEvents = {
LOGIN_SUCCESS: 'Login Success',
@@ -53,6 +57,9 @@ export const AnalyticsEvents = {
MESSAGE_RATING_REASON: 'Message Rating Reason',
MESSAGE_EXPORTED: 'Message Exported',
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
LANDING_PRIMARY_CTA: 'Landing Primary CTA',
LANDING_PRICING_CTA: 'Landing Pricing CTA',
WAITLIST_JOINED: 'Waitlist Joined',
} as const;
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
@@ -110,6 +117,7 @@ export const identifyUser = (userInfo: Record<string, unknown>): void => {
/** Identify with stable non-sensitive traits only (no names / prompts). */
export const identifyAccount = (account: Account): void => {
identifyHeyCatch(account);
const traits: Record<string, unknown> = {
userId: account.email,
};
+10
View File
@@ -0,0 +1,10 @@
export const ORIGIN: string;
export const OG_IMAGE: string;
export const PAGE_PATHS: readonly string[];
export const PAGE_H1: Readonly<Record<string, string>>;
export function canonicalForPath(pathname: string): string | null;
export function h1ForPath(pathname: string): string | null;
export const PAGE_TITLES: Readonly<Record<string, string>>;
export function titleForPath(pathname: string): string | null;
export const PAGE_DESCRIPTIONS: Readonly<Record<string, string>>;
export function descriptionForPath(pathname: string): string | null;
+166
View File
@@ -0,0 +1,166 @@
/** Public origin. Each page's canonical is this origin plus that page's own path. */
const ORIGIN = 'https://hesychia.ai';
const OG_IMAGE = `${ORIGIN}/og-image.png`;
/** Real routes from App.tsx. `/` is the shared index.html; other paths get their own file at build. */
const PAGE_PATHS = [
'/',
'/pricing/',
'/privacy/',
'/changelog/',
'/terms_of_service/',
'/legal/terms-of-service',
'/account-deletion/',
'/signin/',
'/signup/',
'/auth/callback/',
'/billing/success',
'/billing/cancel',
'/password_reset/',
'/password_reset_confirmation/',
'/set_password/',
'/account/',
'/document_storage',
'/feedback/',
'/analytics/',
];
/**
* The one headline already on that page. Static HTML must use this text;
* the React page renders the same words in its single h1.
* @type {Record<string, string>}
*/
const PAGE_H1 = {
'/': 'Hesychia is a calm AI assistant.',
'/pricing/': 'Pricing',
'/privacy/': 'Privacy',
'/changelog/': 'Changelog',
'/terms_of_service/': 'Terms of Service',
'/legal/terms-of-service': 'Terms of Service',
'/account-deletion/': 'Hesychia — Request account deletion',
'/signin/': 'Hesychia.ai',
'/signup/': 'Sign Up',
'/auth/callback/': 'Sign In',
'/billing/success': 'Payment received',
'/billing/cancel': 'Checkout canceled',
'/password_reset/': 'Reset Password',
'/password_reset_confirmation/': 'Check Your Email',
'/set_password/': 'Hesychia',
'/account/': 'Account — Hesychia',
'/document_storage': 'Documents — Hesychia',
'/feedback/': 'Feedback — Hesychia',
'/analytics/': 'Analytics — Hesychia',
};
/**
* @param {string} pathname
* @returns {string | null}
*/
function canonicalForPath(pathname) {
if (!PAGE_PATHS.includes(pathname)) return null;
return `${ORIGIN}${pathname}`;
}
/**
* @param {string} pathname
* @returns {string | null}
*/
function h1ForPath(pathname) {
return PAGE_H1[pathname] || null;
}
/**
* Unique document titles, 30–60 characters as rendered. Main term first.
* Too short or too long throws. Nothing is sliced to fit.
* @type {Record<string, string>}
*/
const PAGE_TITLES = {
'/': 'Hesychia — quiet, focused AI chat',
'/pricing/': 'Hesychia pricing — $10/month, no ads',
'/privacy/': 'Hesychia privacy — stored, never sold',
'/changelog/': 'Hesychia changelog — dated product updates',
'/terms_of_service/': 'Hesychia terms of service — binding terms',
'/legal/terms-of-service': 'Hesychia legal terms — terms of service',
'/account-deletion/': 'Hesychia account deletion — soft-delete',
'/signin/': 'Sign in to Hesychia — quiet, focused chat',
'/signup/': 'Sign up for Hesychia — $10/month, no ads',
'/auth/callback/': 'Hesychia sign-in — returning you to chat',
'/billing/success': 'Hesychia payment received — subscription on',
'/billing/cancel': 'Hesychia checkout canceled — no charge made',
'/password_reset/': 'Reset your Hesychia password — email link',
'/password_reset_confirmation/': 'Hesychia password reset — check your email',
'/set_password/': 'Set a new Hesychia password — account access',
'/account/': 'Hesychia account — profile and subscription',
'/document_storage': 'Hesychia documents — stored and searchable',
'/feedback/': 'Hesychia feedback — tell us what to change',
'/analytics/': 'Hesychia analytics — usage for your account',
};
/**
* @param {string} pathname
* @returns {string | null}
*/
function titleForPath(pathname) {
const title = PAGE_TITLES[pathname];
if (!title) return null;
if (title.length < 30 || title.length > 60) {
throw new Error(
`title for ${pathname} is ${title.length} characters (want 30-60), left uncut: ${title}`,
);
}
return title;
}
/**
* What the reader gets on that page, 110–160 characters. No silent trim.
* @type {Record<string, string>}
*/
const PAGE_DESCRIPTIONS = {
'/': 'Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files.',
'/pricing/': '$10/month, flat. No ads, no upsells, one price. One public plan includes AI chat, your documents, and business accounts for your team.',
'/privacy/': 'Hesychia stores your account, conversations, and uploaded documents so answers can use your files. It does not sell your information or use advertising cookies.',
'/changelog/': 'Dated notes of shipped Hesychia changes: conversation memory, account deletion, document upload, and subscriptions you can upgrade, change, or cancel.',
'/terms_of_service/': 'These Terms of Service are a legally binding agreement between you and AI ML Operations, LLC governing access to and use of the Hesychia chat service.',
'/legal/terms-of-service': 'Public copy of the Hesychia Terms of Service, last updated July 27, 2026. The terms govern use of the chat service operated by AI ML Operations, LLC.',
'/account-deletion/': 'Steps to request deletion of a Hesychia account without signing in. Deletion is a soft-delete: profile, conversations, and documents may remain.',
'/signin/': 'Sign in to an existing Hesychia account with email and password, or use the SSO buttons when registration is open. New visitors can create an account.',
'/signup/': 'Create a Hesychia account, then complete payment on Stripe. The public price is $10/month with no ads and no upsells. Sign in if you already have an account.',
'/auth/callback/': 'Hesychia is finishing sign-in and sending you back to the app. If the provider returns an error, this page shows it and links back to the sign-in form.',
'/billing/success': 'Your Hesychia subscription checkout completed and no further payment step is required here. Sign in with the account you just created to continue into the app.',
'/billing/cancel': 'No charge was made. Your Hesychia account may already exist. Sign in and open billing again, or return to sign up to retry Stripe Checkout.',
'/password_reset/': 'Enter the email on your Hesychia account and we will send a link to set a new password. The link opens a page where you choose that password.',
'/password_reset_confirmation/': 'If an account exists for that address, Hesychia sent a link to set a new password. Check the inbox for that email, then return to sign in.',
'/set_password/': 'Choose a new password for your Hesychia account using the link from the reset email. After it is saved, sign in with that password.',
'/account/': 'View the Hesychia account profile and subscription. Upgrade, change, or cancel the plan, and company managers can invite or update team accounts.',
'/document_storage': 'Upload documents to Hesychia and search them from chat. Personal and company libraries stay separate. Files remain until you delete them or delete the account.',
'/feedback/': 'Submit feedback about Hesychia and read feedback you already sent. The form records the note you write. It does not change your subscription or documents.',
'/analytics/': 'See Hesychia usage for your account: prompt volume, conversation volume, team seat activity, and response times. Company managers can open company insights.',
};
/**
* @param {string} pathname
* @returns {string | null}
*/
function descriptionForPath(pathname) {
const description = PAGE_DESCRIPTIONS[pathname];
if (!description) return null;
if (description.length < 110 || description.length > 160) {
throw new Error(
`description for ${pathname} is ${description.length} characters (want 110-160), left uncut: ${description}`,
);
}
return description;
}
module.exports = {
ORIGIN,
OG_IMAGE,
PAGE_PATHS,
PAGE_H1,
PAGE_TITLES,
PAGE_DESCRIPTIONS,
canonicalForPath,
h1ForPath,
titleForPath,
descriptionForPath,
};
+5
View File
@@ -127,6 +127,11 @@ describe('finance helpers', () => {
it('classifies subscription sources', () => {
expect(isStoreSubscription('revenuecat')).toBe(true);
expect(isStoreSubscription('app_store')).toBe(true);
expect(isStoreSubscription('play_store')).toBe(true);
expect(invoiceProviderLabel(baseInvoice({ provider: 'app_store' }))).toBe(
'App Store'
);
expect(isStripeSubscription('stripe')).toBe(true);
expect(
isComplimentarySubscription(
+10 -1
View File
@@ -55,6 +55,7 @@ export type SubscriptionPlanInfo = {
prompt_window_hours: number;
monthly_token_quota: number | null;
sort_order: number;
store_product_id?: string;
};
export type SubscriptionUsage = {
@@ -81,6 +82,7 @@ export type SubscriptionMe = {
revenuecat_original_transaction_id?: string;
cancel_at_period_end?: boolean;
current_period_end?: string | null;
store_account_token?: string;
usage: SubscriptionUsage;
};
@@ -88,6 +90,8 @@ export type SubscriptionSource =
| 'none'
| 'stripe'
| 'revenuecat'
| 'app_store'
| 'play_store'
| 'backer'
| 'admin'
| string;
@@ -95,7 +99,10 @@ export type SubscriptionSource =
export function isStoreSubscription(
source: string | null | undefined
): boolean {
return source === 'revenuecat';
const value = (source || '').toLowerCase();
return (
value === 'revenuecat' || value === 'app_store' || value === 'play_store'
);
}
export function isStripeSubscription(
@@ -109,6 +116,8 @@ export function invoiceProviderLabel(
invoice: Pick<FinanceInvoice, 'provider' | 'revenuecat_store'>
): string {
const provider = (invoice.provider || '').toLowerCase();
if (provider === 'app_store') return 'App Store';
if (provider === 'play_store') return 'Play Store';
if (provider === 'revenuecat') {
const store = (invoice.revenuecat_store || '').toUpperCase();
if (
+31
View File
@@ -0,0 +1,31 @@
import { analytics } from '@heycatch/sdk';
import type { Account } from '../data';
import { isProductAnalyticsEnabled } from './productAnalytics';
if (isProductAnalyticsEnabled()) {
analytics.init({
projectKey: 'hck_pk_jTFCp-wijbruu8IV50prV0c9W3Sb_kYd',
install: {
framework: 'react',
frameworkVersion: '18',
agent: 'cursor',
},
});
}
export function identifyHeyCatch(account: Account): void {
if (account.id == null) return;
const name = [account.first_name, account.last_name].filter(Boolean).join(' ');
analytics.setIdentity(String(account.id), {
email: account.email,
...(name ? { name } : {}),
});
}
export function resetHeyCatch(): void {
analytics.resetIdentity();
}
export function trackHeyCatch(eventName: string): void {
analytics.trackEvent(eventName);
}
+7
View File
@@ -0,0 +1,7 @@
export type InternalLink = {
href: string;
label: string;
};
export function linksForPath(pathname: string): InternalLink[];
export function relatedNavHtml(pathname: string): string;
+87
View File
@@ -0,0 +1,87 @@
/**
* Neighbours for the public cluster. Anchors name the page; none say "here".
* App routes fall back to the overview's neighbours so a stamped shell is not an orphan.
*/
/** @typedef {{ href: string, label: string }} InternalLink */
/** @type {Record<string, InternalLink[]>} */
const RELATED = {
'/': [
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/changelog/', label: 'Dated Hesychia product updates' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
{ href: '/account-deletion/', label: 'How to request Hesychia account deletion' },
],
'/pricing/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/changelog/', label: 'Dated Hesychia product updates' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
],
'/privacy/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
{ href: '/account-deletion/', label: 'How to request Hesychia account deletion' },
],
'/changelog/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
],
'/terms_of_service/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/account-deletion/', label: 'How to request Hesychia account deletion' },
],
'/account-deletion/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
],
};
const GENERIC_ANCHOR = /^(here|click here|read more|learn more)$/i;
/**
* @param {string} pathname
* @returns {InternalLink[]}
*/
function linksForPath(pathname) {
if (pathname === '/legal/terms-of-service') return RELATED['/terms_of_service/'];
return RELATED[pathname] || RELATED['/'].slice(0, 4);
}
/**
* @param {string} pathname
* @returns {string}
*/
function relatedNavHtml(pathname) {
const links = linksForPath(pathname);
if (links.length < 2 || links.length > 5) {
throw new Error(`related links for ${pathname} must be 2-5, found ${links.length}`);
}
const inner = links
.map((link) => {
if (link.href === pathname) {
throw new Error(`related nav on ${pathname} links to itself`);
}
if (GENERIC_ANCHOR.test(link.label.trim())) {
throw new Error(`generic anchor on ${pathname}: ${link.label}`);
}
return `<a href="${link.href}">${link.label}</a>`;
})
.join('');
return `<nav aria-label="Related pages">${inner}</nav>`;
}
module.exports = {
RELATED,
linksForPath,
relatedNavHtml,
};
+67
View File
@@ -0,0 +1,67 @@
export const HOME: {
lede: string;
memory: string;
stored: string;
notSold: string;
cancel: string;
memoryFeature: string;
documentsFeature: string;
businessFeature: string;
earlyAdopter: string;
roadmapImage: string;
roadmapVision: string;
};
export const PRICING: {
price: string;
lock: string;
plan: string;
compare: string;
};
export const PRIVACY: {
stored: string;
analytics: string;
notSold: string;
thirdParties: string;
binding: string;
cancel: string;
documents: string;
softDelete: string;
purge: string;
};
export const CHANGELOG: {
memory: string;
memoryOff: string;
deletion: string;
documents: string;
subscriptions: string;
};
export const ACCOUNT_DELETION: {
intro: string;
open: string;
signIn: string;
goToAccount: string;
confirm: string;
deactivated: string;
loseAccess: string;
sessions: string;
softDelete: string;
remain: string;
includes: string;
purposes: string;
retention: string;
request: string;
noClaim: string;
contact: string;
email: string;
days: string;
staff: string;
};
export const NOT_FOUND_SENTENCES: readonly string[];
export function sentencesForPath(pathname: string): string[];
export function proseHtml(pathname: string): string;
export function wordCount(sentence: string): number;
+237
View File
@@ -0,0 +1,237 @@
/**
* Visible sentences for each public route. Each one is 10–25 words.
*/
const { PAGE_PATHS } = require('./canonicals');
/** @param {string} sentence */
function wordCount(sentence) {
return sentence
.replace(/[—–]/g, ' ')
.replace(/[^A-Za-z0-9$/'’.+-]+/g, ' ')
.split(/\s+/)
.filter(Boolean).length;
}
const EARLY_ADOPTER =
'Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.';
const HOME = {
lede: 'Quiet, focused AI chat — private by default — is how this Hesychia product is built.',
memory:
'It remembers what you told it and your documents, so you never start over, and conversation memory is a setting you control.',
stored: 'We store your account, your conversations, and the documents you upload.',
notSold: 'We do not sell your data, and we do not use advertising cookies.',
cancel:
'Canceling does not delete documents, and they stay until you delete them or delete the account, and account deletion is a soft-delete.',
memoryFeature: 'It remembers what you told it and your documents, so you never start over.',
documentsFeature:
'Your documents stay stored and searchable, and answers are grounded in your own files.',
businessFeature:
'Business accounts are simple management for your team, and company managers can manage team accounts.',
earlyAdopter: EARLY_ADOPTER,
roadmapImage:
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
roadmapVision:
'Vision is on the roadmap, so you can ask questions about an image you upload.',
};
const PRICING = {
price: '$10/month, flat, with no ads, no upsells, and one price.',
lock: EARLY_ADOPTER,
plan: 'One public plan covers quiet, focused AI chat, your documents, and business accounts for your team.',
compare: 'Hard to justify $20 a month, and this is $10.',
};
const PRIVACY = {
stored:
'Your account (email, name, and an optional company), your conversations, and the documents you upload so answers can use your files.',
analytics:
'Optional product analytics, if you accept them, record actions such as sign-in and checkout, and they do not include message or document contents.',
notSold: 'We do not sell your information, and we do not use advertising cookies.',
thirdParties:
'Information you enter is not shared with third parties without your consent, except to operate the service or when the law requires it.',
binding: 'The Terms of Service and the analytics section are the binding description.',
cancel: 'Canceling stops paid access at the end of the current period.',
documents:
'It does not delete uploaded documents, and documents stay in your workspace until you delete them or until you delete the account.',
softDelete:
'Account deletion is a soft-delete of profile details, conversations, uploaded documents, and authentication records.',
purge:
'Those records may remain for administration, security, and audit, and there is no automatic hard purge.',
};
const CHANGELOG = {
memory:
'Account settings can use earlier turns in a chat as context, so you do not re-explain the same project on every message.',
memoryOff: 'It stays off until you turn it on in account settings.',
deletion: 'A public page explains how to delete an account and that deletion is a soft-delete.',
documents:
'Upload documents and search them from chat, and personal and company libraries stay separate.',
subscriptions:
'Upgrade, change, or cancel from the account page, and the public price is $10 a month.',
};
const ACCOUNT_DELETION = {
intro:
'Use the steps below to request deletion of your Hesychia account, and this page does not require sign-in to read.',
open: 'Open Hesychia in the mobile app or at hesychia.ai for your account.',
signIn: 'Sign in with the account you want deleted before you continue.',
goToAccount: 'Go to the Account page after you sign in to Hesychia.',
confirm: 'Choose Delete my account, then confirm by typing your account email.',
deactivated:
'Your account is marked deleted and set inactive, and your conversations are hidden from the product.',
loseAccess: 'You lose access to Hesychia and cannot sign in again with that account.',
sessions: 'Active sessions are invalidated, and the refresh tokens are blacklisted.',
softDelete:
'Hesychia currently uses a soft-delete process, and this is not a full erasure of all stored data.',
remain: 'After you delete your account, personal data associated with the account may remain.',
includes:
'That data includes profile details, conversation content, uploaded documents, RAG vectors, and authentication event records.',
purposes: 'The data may remain for administration, security, and audit purposes.',
retention:
'There is currently no automatic hard-purge schedule, and soft-deleted data is retained until a future purge process is implemented.',
request:
'It is also retained until we process a separate retention or erasure request through the contact path below.',
noClaim: 'We will not claim complete erasure while soft-delete-only remains in effect.',
contact:
'If you cannot sign in to complete in-app deletion, contact AI ML Operations, LLC via aimloperations.com.',
email: 'Include the email address used for your Hesychia account in that request.',
days: 'We aim to respond to account-deletion requests within 30 days.',
staff: 'Staff accounts cannot self-delete in the app, so use the contact path above.',
};
const TERMS = [
'These Terms of Service are a legally binding agreement between you and AI ML Operations, LLC.',
'The agreement governs access to and use of the Hesychia chat service.',
];
const LEGAL_TERMS = [
'This is the public copy of the Hesychia Terms of Service, last updated July 27, 2026.',
'The terms govern use of the chat service operated by AI ML Operations, LLC.',
];
/** @type {Record<string, string[]>} */
const PAGE_SENTENCES = {
'/': Object.values(HOME),
'/pricing/': Object.values(PRICING),
'/privacy/': Object.values(PRIVACY),
'/changelog/': Object.values(CHANGELOG),
'/terms_of_service/': TERMS,
'/legal/terms-of-service': LEGAL_TERMS,
'/account-deletion/': Object.values(ACCOUNT_DELETION),
'/signin/': [
'Sign in to an existing Hesychia account with email and password, or use the SSO buttons when registration is open.',
'New visitors can create an account on the sign-up page.',
],
'/signup/': [
'Create a Hesychia account, then complete payment on Stripe for the public price of $10/month with no ads and no upsells.',
'Sign in on the sign-in page if you already have a Hesychia account.',
],
'/auth/callback/': [
'Hesychia is finishing sign-in and sending you back to the app.',
'If the provider returns an error, this page shows it and links back to the sign-in form.',
],
'/billing/success': [
'Your Hesychia subscription checkout completed and no further payment step is required here.',
'Sign in with the account you just created to continue into the app.',
],
'/billing/cancel': [
'No charge was made, and your Hesychia account may already exist.',
'Sign in and open billing again, or return to sign up to retry Stripe Checkout.',
],
'/password_reset/': [
'Enter the email on your Hesychia account and we will send a link to set a new password.',
'The link opens a page where you choose that password.',
],
'/password_reset_confirmation/': [
'If an account exists for that address, Hesychia sent a link to set a new password.',
'Check the inbox for that email, then return to sign in.',
],
'/set_password/': [
'Choose a new password for your Hesychia account using the link from the reset email.',
'After the new password is saved, sign in with that password.',
],
'/account/': [
'View the Hesychia account profile and subscription on this page.',
'Upgrade, change, or cancel the plan, and company managers can invite or update team accounts.',
],
'/document_storage': [
'Upload documents to Hesychia and search them from chat, and personal and company libraries stay separate.',
'Files remain until you delete them or delete the account.',
],
'/feedback/': [
'Submit feedback about Hesychia and read feedback you already sent.',
'The form records the note you write, and it does not change your subscription or documents.',
],
'/analytics/': [
'See Hesychia usage for your account, including prompt volume, conversation volume, team seat activity, and response times.',
'Company managers can open the company insights for this account.',
],
};
const NOT_FOUND_SENTENCES = [
'The page is not found, and 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.',
];
/**
* @param {string} pathname
* @param {string[]} sentences
*/
function assertSentenceBand(pathname, sentences) {
if (sentences.length < 2) {
throw new Error(`${pathname} needs at least two sentences`);
}
const counts = sentences.map(wordCount);
counts.forEach((count, index) => {
if (count < 10 || count > 25) {
throw new Error(
`${pathname} sentence ${index + 1} is ${count} words (want 10-25): ${sentences[index]}`,
);
}
});
const average = counts.reduce((sum, count) => sum + count, 0) / counts.length;
if (average < 10 || average > 25) {
throw new Error(`${pathname} average sentence length is ${average}`);
}
}
for (const pathname of PAGE_PATHS) {
const sentences = PAGE_SENTENCES[pathname];
if (!sentences) throw new Error(`no prose for ${pathname}`);
assertSentenceBand(pathname, sentences);
}
assertSentenceBand('/404.html', NOT_FOUND_SENTENCES);
/**
* @param {string} pathname
* @returns {string[]}
*/
function sentencesForPath(pathname) {
const sentences = PAGE_SENTENCES[pathname];
if (!sentences) throw new Error(`no prose for ${pathname}`);
return sentences;
}
/**
* @param {string} pathname
* @returns {string}
*/
function proseHtml(pathname) {
const inner = sentencesForPath(pathname)
.map((sentence) => `<p>${sentence}</p>`)
.join('');
return `<div id="crawler-prose">${inner}</div>`;
}
module.exports = {
HOME,
PRICING,
PRIVACY,
CHANGELOG,
ACCOUNT_DELETION,
NOT_FOUND_SENTENCES,
sentencesForPath,
proseHtml,
wordCount,
};
@@ -0,0 +1,9 @@
import { Capacitor } from '@capacitor/core';
/** Production website, or the iOS / Android app. Beta and local web stay off. */
export function isProductAnalyticsEnabled(): boolean {
const platform = Capacitor.getPlatform();
const nativeApp = platform === 'ios' || platform === 'android';
const productionWeb = process.env.REACT_APP_DEPLOY_ENV === 'production';
return nativeApp || productionWeb;
}
@@ -1,14 +1,3 @@
jest.mock('@revenuecat/purchases-capacitor', () => ({
Purchases: {
configure: jest.fn(),
logIn: jest.fn(),
logOut: jest.fn(),
getOfferings: jest.fn(),
purchasePackage: jest.fn(),
restorePurchases: jest.fn(),
},
}));
import {
resolveAppUserId,
isPurchaseCancelledError,
+23 -140
View File
@@ -1,44 +1,15 @@
/**
* RevenueCat Capacitor IAP wrapper (#100).
* No-ops on web; only configures / purchases on native platforms.
* Mobile purchase entry points kept under the old names so sign-in and
* billing call sites stay stable.
* Purchases run through StoreKit / Play Billing (`storeBilling`), then the
* backend verifies the store transaction. These identity hooks are no-ops:
* the store account token comes from `/monetization/subscription/`.
*/
import { Purchases } from '@revenuecat/purchases-capacitor';
import type { PurchasesPackage } from '@revenuecat/purchases-capacitor';
import { decodeJwtPayload } from '../auth/jwtHelpers';
import { getAccessToken } from '../auth/tokenStorage';
import { isNativePlatform } from '../platform/nativePlatform';
let configurePromise: Promise<void> | null = null;
let configured = false;
type AccountLike = { id?: number | string | null };
function getCapacitorPlatform(): string {
if (typeof window === 'undefined') return 'web';
const Cap = window.Capacitor as
| { getPlatform?: () => string; isNativePlatform?: () => boolean }
| undefined;
if (typeof Cap?.getPlatform === 'function') {
return Cap.getPlatform();
}
return 'web';
}
function revenueCatApiKey(): string {
const platform = getCapacitorPlatform();
if (platform === 'ios') {
return (process.env.REACT_APP_REVENUECAT_APPLE_API_KEY || '').trim();
}
if (platform === 'android') {
return (process.env.REACT_APP_REVENUECAT_GOOGLE_API_KEY || '').trim();
}
// Fallback: prefer Google then Apple if platform unknown on native.
return (
(process.env.REACT_APP_REVENUECAT_GOOGLE_API_KEY || '').trim() ||
(process.env.REACT_APP_REVENUECAT_APPLE_API_KEY || '').trim()
);
}
/** Prefer numeric user pk; fall back to JWT `user_id` claim. */
export function resolveAppUserId(account?: AccountLike | null): string | null {
if (account?.id != null && String(account.id).trim() !== '') {
@@ -53,124 +24,36 @@ export function resolveAppUserId(account?: AccountLike | null): string | null {
return null;
}
export async function configureRevenueCat(): Promise<boolean> {
if (!isNativePlatform()) return false;
if (configured) return true;
if (configurePromise) {
await configurePromise;
return configured;
}
configurePromise = (async () => {
const apiKey = revenueCatApiKey();
if (!apiKey) {
console.warn(
'RevenueCat API key missing (REACT_APP_REVENUECAT_APPLE_API_KEY / GOOGLE).'
);
return;
}
await Purchases.configure({ apiKey });
configured = true;
})();
try {
await configurePromise;
} finally {
configurePromise = null;
}
return configured;
}
export async function logInRevenueCat(
appUserIdOrAccount?: string | AccountLike | null
_appUserIdOrAccount?: string | AccountLike | null
): Promise<void> {
if (!isNativePlatform()) return;
const appUserID =
typeof appUserIdOrAccount === 'string'
? appUserIdOrAccount
: resolveAppUserId(appUserIdOrAccount);
if (!appUserID) {
console.warn('RevenueCat logIn skipped: no app user id');
return;
}
const ready = await configureRevenueCat();
if (!ready) return;
await Purchases.logIn({ appUserID });
return;
}
export async function logOutRevenueCat(): Promise<void> {
if (!isNativePlatform()) return;
if (!configured) return;
try {
await Purchases.logOut();
} catch (error) {
// Anonymous / already logged out — ignore.
console.warn('RevenueCat logOut', error);
}
}
function packageMatchesPlan(pkg: PurchasesPackage, planSlug: string): boolean {
const slug = planSlug.toLowerCase();
const id = (pkg.identifier || '').toLowerCase();
const productId = (pkg.product?.identifier || '').toLowerCase();
return id === slug || id.includes(slug) || productId.includes(slug);
}
async function resolvePackageForPlan(
planSlug?: string
): Promise<PurchasesPackage> {
const offerings = await Purchases.getOfferings();
const offeringId = (process.env.REACT_APP_REVENUECAT_OFFERING_ID || '').trim();
const offering =
(offeringId && offerings.all?.[offeringId]) || offerings.current || null;
if (!offering) {
throw new Error('No RevenueCat offerings available. Check the RC dashboard.');
}
const packages = offering.availablePackages || [];
if (!packages.length) {
throw new Error('RevenueCat offering has no packages.');
}
if (planSlug) {
const match = packages.find((pkg) => packageMatchesPlan(pkg, planSlug));
if (match) return match;
throw new Error(`No store package matches plan "${planSlug}".`);
}
return (
offering.monthly ||
packages.find((pkg) => (pkg.identifier || '').includes('monthly')) ||
packages[0]
);
return;
}
export async function purchasePlan(planSlug?: string): Promise<void> {
if (!isNativePlatform()) {
throw new Error('Store purchases are only available in the mobile app.');
}
const ready = await configureRevenueCat();
if (!ready) {
throw new Error('RevenueCat is not configured. Missing API key.');
}
const aPackage = await resolvePackageForPlan(planSlug);
await Purchases.purchasePackage({ aPackage });
const { purchaseNativePlan } = await import('./storeBilling');
return purchaseNativePlan(planSlug);
}
export async function restorePurchases(): Promise<void> {
if (!isNativePlatform()) {
throw new Error('Restore is only available in the mobile app.');
}
const ready = await configureRevenueCat();
if (!ready) {
throw new Error('RevenueCat is not configured. Missing API key.');
}
await Purchases.restorePurchases();
const { restoreNativePurchases } = await import('./storeBilling');
return restoreNativePurchases();
}
export function isPurchaseCancelledError(error: unknown): boolean {
const err = error as { userCancelled?: boolean; code?: number | string };
if (err?.userCancelled === true) return true;
// PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR === 1
return err?.code === 1 || err?.code === '1' || err?.code === 'PURCHASE_CANCELLED';
const err = error as {
userCancelled?: boolean;
code?: number | string;
message?: string;
data?: { userCancelled?: boolean };
};
if (err?.userCancelled === true || err?.data?.userCancelled === true) return true;
if (err?.code === 1 || err?.code === '1' || err?.code === 'PURCHASE_CANCELLED') {
return true;
}
return typeof err?.message === 'string' && /purchase cancelled/i.test(err.message);
}
+83
View File
@@ -0,0 +1,83 @@
import { PAGE_PATHS } from './canonicals';
import { applyRouteMeta, metaForPath } from './routeMeta';
describe('metaForPath', () => {
it('uses the marketing title for anonymous home', () => {
expect(metaForPath('/', false)).toEqual({
title: 'Hesychia — quiet, focused AI chat',
description:
'Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files.',
canonical: 'https://hesychia.ai/',
});
});
it('keeps the app title for a signed-in home', () => {
expect(metaForPath('/', true)?.title).toBe('Hesychia');
});
it('gives public pages their own titles and canonicals', () => {
expect(metaForPath('/pricing/', false)).toEqual({
title: 'Hesychia pricing — $10/month, no ads',
description:
'$10/month, flat. No ads, no upsells, one price. One public plan includes AI chat, your documents, and business accounts for your team.',
canonical: 'https://hesychia.ai/pricing/',
});
expect(metaForPath('/privacy/', false)?.canonical).toBe('https://hesychia.ai/privacy/');
expect(metaForPath('/changelog/', false)?.title).toBe('Hesychia changelog — dated product updates');
expect(metaForPath('/signin/', false)?.canonical).toBe('https://hesychia.ai/signin/');
});
it('gives every real page a self-referencing canonical', () => {
for (const pathname of PAGE_PATHS) {
expect(metaForPath(pathname, false)?.canonical).toBe(`https://hesychia.ai${pathname}`);
}
});
it('does not invent a title for an unknown path', () => {
expect(metaForPath('/not-a-page', false)).toBeNull();
});
});
describe('applyRouteMeta', () => {
afterEach(() => {
document.title = '';
document.head.querySelectorAll('meta').forEach((node) => node.remove());
document.querySelector('link[rel="canonical"]')?.remove();
});
it('sets the title, canonical, and social card tags', () => {
applyRouteMeta({
title: 'Pricing — Hesychia',
description: 'Pricing page description that states the public plan.',
canonical: 'https://hesychia.ai/pricing/',
});
expect(document.title).toBe('Pricing — Hesychia');
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
'https://hesychia.ai/pricing/',
);
expect(document.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
'Pricing — Hesychia',
);
expect(document.querySelector('meta[property="og:image"]')?.getAttribute('content')).toBe(
'https://hesychia.ai/og-image.png',
);
expect(document.querySelector('meta[name="twitter:card"]')?.getAttribute('content')).toBe(
'summary_large_image',
);
expect(document.querySelector('meta[property="og:url"]')?.getAttribute('content')).toBe(
'https://hesychia.ai/pricing/',
);
});
it('removes a canonical when the route should not have one', () => {
applyRouteMeta({
title: 'Pricing — Hesychia',
description: null,
canonical: 'https://hesychia.ai/pricing/',
});
applyRouteMeta({ title: 'Sign in — Hesychia', description: null, canonical: null });
expect(document.title).toBe('Sign in — Hesychia');
expect(document.querySelector('link[rel="canonical"]')).toBeNull();
expect(document.querySelector('meta[property="og:url"]')).toBeNull();
});
});
+98
View File
@@ -0,0 +1,98 @@
import { canonicalForPath, descriptionForPath, OG_IMAGE, titleForPath } from './canonicals';
export type RouteMeta = {
/** Null leaves the current document title alone. */
title: string | null;
/** Null leaves the current meta description alone. */
description: string | null;
/** Absolute canonical URL. Null means this path should not advertise a canonical. */
canonical: string | null;
};
const page = (pathname: string): RouteMeta => ({
title: titleForPath(pathname),
description: descriptionForPath(pathname),
canonical: canonicalForPath(pathname),
});
const PUBLIC_PAGES: Record<string, RouteMeta> = {
'/': page('/'),
'/pricing/': page('/pricing/'),
'/privacy/': page('/privacy/'),
'/changelog/': page('/changelog/'),
'/terms_of_service/': page('/terms_of_service/'),
'/legal/terms-of-service': page('/legal/terms-of-service'),
'/account-deletion/': page('/account-deletion/'),
'/signin/': page('/signin/'),
'/signup/': page('/signup/'),
'/auth/callback/': page('/auth/callback/'),
'/billing/success': page('/billing/success'),
'/billing/cancel': page('/billing/cancel'),
'/password_reset/': page('/password_reset/'),
'/password_reset_confirmation/': page('/password_reset_confirmation/'),
'/set_password/': page('/set_password/'),
'/account/': page('/account/'),
'/document_storage': page('/document_storage'),
'/feedback/': page('/feedback/'),
'/analytics/': page('/analytics/'),
};
/** Title and canonical for a path. Signed-in `/` keeps the app title. */
export function metaForPath(pathname: string, authenticated: boolean): RouteMeta | null {
if (pathname === '/' && authenticated) {
return { title: 'Hesychia', description: null, canonical: canonicalForPath('/') };
}
const known = PUBLIC_PAGES[pathname];
if (known) return known;
const canonical = canonicalForPath(pathname);
if (!canonical) return null;
return { title: null, description: null, canonical };
}
function upsertMeta(attr: 'name' | 'property', key: string, content: string): void {
const selector = `meta[${attr}="${key}"]`;
const current = document.querySelector<HTMLMetaElement>(selector);
const el = current ?? document.createElement('meta');
el.setAttribute(attr, key);
el.content = content;
if (!current) document.head.appendChild(el);
}
export function applyRouteMeta(meta: RouteMeta | null): void {
if (!meta || typeof document === 'undefined') return;
if (meta.title) {
document.title = meta.title;
upsertMeta('property', 'og:title', meta.title);
upsertMeta('name', 'twitter:title', meta.title);
}
if (meta.description) {
const current = document.querySelector<HTMLMetaElement>('meta[name="description"]');
const description = current ?? document.createElement('meta');
description.name = 'description';
description.content = meta.description;
if (!current) document.head.appendChild(description);
upsertMeta('property', 'og:description', meta.description);
upsertMeta('name', 'twitter:description', meta.description);
}
upsertMeta('property', 'og:image', OG_IMAGE);
upsertMeta('name', 'twitter:image', OG_IMAGE);
upsertMeta('name', 'twitter:card', 'summary_large_image');
const existing = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!meta.canonical) {
existing?.parentNode?.removeChild(existing);
document.querySelector('meta[property="og:url"]')?.remove();
return;
}
const link = existing ?? document.createElement('link');
link.rel = 'canonical';
link.href = meta.canonical;
if (!existing) {
document.head.appendChild(link);
}
upsertMeta('property', 'og:url', meta.canonical);
}
@@ -0,0 +1,99 @@
jest.mock('../../axiosApi', () => ({
axiosInstance: {
get: jest.fn(),
post: jest.fn(),
},
}));
jest.mock('../platform/nativePlatform', () => ({
isNativePlatform: jest.fn(() => true),
}));
jest.mock('../plugins/nativeStoreBilling', () => ({
NativeStoreBilling: {
purchase: jest.fn(),
restore: jest.fn(),
},
}));
import { axiosInstance } from '../../axiosApi';
import { isNativePlatform } from '../platform/nativePlatform';
import { NativeStoreBilling } from '../plugins/nativeStoreBilling';
import { purchaseNativePlan, restoreNativePurchases } from './storeBilling';
const mockedGet = axiosInstance.get as jest.Mock;
const mockedPost = axiosInstance.post as jest.Mock;
const mockedNative = isNativePlatform as jest.Mock;
const mockedPurchase = NativeStoreBilling.purchase as jest.Mock;
const mockedRestore = NativeStoreBilling.restore as jest.Mock;
describe('storeBilling', () => {
beforeEach(() => {
mockedGet.mockReset();
mockedPost.mockReset();
mockedPurchase.mockReset();
mockedRestore.mockReset();
mockedNative.mockReturnValue(true);
mockedPost.mockResolvedValue({ data: {} });
mockedGet.mockImplementation((url: string) => {
if (url.includes('plans')) {
return Promise.resolve({
data: [{ slug: 'founders', store_product_id: 'hesychia_founders_monthly' }],
});
}
return Promise.resolve({ data: { store_account_token: 'account-token' } });
});
});
it('confirms an App Store purchase with the signed transaction', async () => {
mockedPurchase.mockResolvedValue({
store: 'app_store',
productId: 'hesychia_founders_monthly',
transactionId: '2001',
signedTransaction: 'header.payload.sig',
});
await purchaseNativePlan('founders');
expect(mockedPurchase).toHaveBeenCalledWith({
productId: 'hesychia_founders_monthly',
accountToken: 'account-token',
});
expect(mockedPost).toHaveBeenCalledWith('/monetization/store/confirm/', {
store: 'app_store',
product_id: 'hesychia_founders_monthly',
transaction_id: '2001',
purchase_token: '',
signed_transaction: 'header.payload.sig',
});
});
it('confirms each restored Play purchase', async () => {
mockedRestore.mockResolvedValue({
transactions: [
{
store: 'play_store',
productId: 'hesychia_founders_monthly',
transactionId: 'GPA.1',
purchaseToken: 'token-1',
},
],
});
await restoreNativePurchases();
expect(mockedPost).toHaveBeenCalledWith(
'/monetization/store/confirm/',
expect.objectContaining({
store: 'play_store',
purchase_token: 'token-1',
})
);
});
it('refuses store purchase on the web', async () => {
mockedNative.mockReturnValue(false);
await expect(purchaseNativePlan()).rejects.toThrow(/mobile app/);
expect(mockedPurchase).not.toHaveBeenCalled();
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Native App Store / Play Store purchases.
* The store sheet runs on device. Entitlement is granted only after
* POST /monetization/store/confirm/ verifies the transaction.
*/
import { axiosInstance } from '../../axiosApi';
import { isNativePlatform } from '../platform/nativePlatform';
import {
NativeStoreBilling,
type NativeStoreTransaction,
} from '../plugins/nativeStoreBilling';
import type { SubscriptionPlanInfo } from './finance';
type SubscriptionTokenResponse = {
store_account_token?: string;
};
async function fetchAccountToken(): Promise<string> {
const subRes = await axiosInstance.get<SubscriptionTokenResponse>(
'/monetization/subscription/'
);
const accountToken = (subRes.data?.store_account_token || '').trim();
if (!accountToken) {
throw new Error('Missing store account token.');
}
return accountToken;
}
async function resolvePurchaseTarget(planSlug?: string): Promise<{
productId: string;
accountToken: string;
}> {
const [plansRes, accountToken] = await Promise.all([
axiosInstance.get<SubscriptionPlanInfo[]>('/monetization/plans/'),
fetchAccountToken(),
]);
const slug = (planSlug || 'founders').toLowerCase();
const plans = Array.isArray(plansRes.data) ? plansRes.data : [];
const plan = plans.find((item) => item.slug === slug);
const productId = (plan?.store_product_id || '').trim();
if (!productId) {
throw new Error(`No store product configured for plan "${slug}".`);
}
return { productId, accountToken };
}
export async function confirmStoreTransaction(
tx: NativeStoreTransaction
): Promise<void> {
await axiosInstance.post('/monetization/store/confirm/', {
store: tx.store,
product_id: tx.productId,
transaction_id: tx.transactionId || '',
purchase_token: tx.purchaseToken || '',
signed_transaction: tx.signedTransaction || '',
});
}
export async function purchaseNativePlan(planSlug?: string): Promise<void> {
if (!isNativePlatform()) {
throw new Error('Store purchases are only available in the mobile app.');
}
const { productId, accountToken } = await resolvePurchaseTarget(planSlug);
const tx = await NativeStoreBilling.purchase({ productId, accountToken });
await confirmStoreTransaction(tx);
}
export async function restoreNativePurchases(): Promise<void> {
if (!isNativePlatform()) {
throw new Error('Restore is only available in the mobile app.');
}
const accountToken = await fetchAccountToken();
const result = await NativeStoreBilling.restore({ accountToken });
const transactions = result?.transactions || [];
if (!transactions.length) {
throw new Error('No active store purchases found.');
}
for (const tx of transactions) {
await confirmStoreTransaction(tx);
}
}
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more