Hide public usage counts until 20 accounts, then round them, and state the lifetime $10 price plus the image and vision roadmap.
Unit Tests / test (pull_request) Successful in 14s
Unit Tests / test (pull_request) Successful in 14s
Closes #123.
This commit is contained in:
1 parent
7a8f413483
commit
8fb4ec71c7
12 files changed
+160
-14
No files matched your search
@@ -83,7 +83,7 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"><h1>Hesychia is a calm AI assistant.</h1><div id="crawler-prose"><p>Quiet, focused AI chat — private by default — is how this Hesychia product is built.</p><p>It remembers what you told it and your documents, so you never start over, and conversation memory is a setting you control.</p><p>We store your account, your conversations, and the documents you upload.</p><p>We do not sell your data, and we do not use advertising cookies.</p><p>Canceling does not delete documents, and they stay until you delete them or delete the account, and account deletion is a soft-delete.</p><p>It remembers what you told it and your documents, so you never start over.</p><p>Your documents stay stored and searchable, and answers are grounded in your own files.</p><p>Business accounts are simple management for your team, and company managers can manage team accounts.</p></div><nav aria-label="Related pages"><a href="/pricing/">Hesychia pricing — $10/month, no ads</a><a href="/privacy/">What Hesychia stores and does not sell</a><a href="/changelog/">Dated Hesychia product updates</a><a href="/terms_of_service/">Hesychia terms of service</a><a href="/account-deletion/">How to request Hesychia account deletion</a></nav></div>
|
||||
<div id="root"><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.
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
> 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. 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.
|
||||
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, flat, no ads, no upsells
|
||||
- [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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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`
|
||||
@@ -26,12 +27,9 @@ type PublicUsage = {
|
||||
as_of: string;
|
||||
};
|
||||
|
||||
function countLabel(count: number, singular: string, plural: string): string {
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live counts only. Hidden when the API fails so the page never shows a made-up number.
|
||||
* 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 => {
|
||||
@@ -65,16 +63,16 @@ const UsageCounts = (): JSX.Element | null => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!usage) return null;
|
||||
if (!usage || !shouldShowPublicUsage(usage.account_count)) return null;
|
||||
|
||||
const asOfDate = usage.as_of.slice(0, 10);
|
||||
|
||||
return (
|
||||
<Section aria-label="Usage">
|
||||
<Figures>
|
||||
{countLabel(usage.account_count, 'account', 'accounts')}
|
||||
{formatPublicCount(usage.account_count, 'accounts')}
|
||||
{' · '}
|
||||
{countLabel(usage.document_count, 'document stored', 'documents stored')}
|
||||
{formatPublicCount(usage.document_count, 'documents stored')}
|
||||
</Figures>
|
||||
<AsOf>
|
||||
As of <time dateTime={usage.as_of}>{asOfDate}</time>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -50,6 +50,12 @@ describe('LandingPage', () => {
|
||||
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();
|
||||
@@ -59,9 +65,34 @@ describe('LandingPage', () => {
|
||||
expect(screen.getAllByRole('link', { name: 'Sign in' }).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole('link', { name: 'Pricing' })[0]).toHaveAttribute('href', '/pricing/');
|
||||
|
||||
expect(await screen.findByText(/3 accounts/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/12 documents stored/)).toBeInTheDocument();
|
||||
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 () => {
|
||||
|
||||
@@ -82,6 +82,29 @@ const FeatureBody = styled.p`
|
||||
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>
|
||||
@@ -90,6 +113,7 @@ const LandingPage = (): JSX.Element => {
|
||||
<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>
|
||||
@@ -115,6 +139,11 @@ const LandingPage = (): JSX.Element => {
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,10 @@ describe('PricingPage', () => {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ const PricingPage = (): JSX.Element => {
|
||||
<PublicSiteLayout>
|
||||
<Title>Pricing</Title>
|
||||
<Price>{PRICING.price}</Price>
|
||||
<Body>{PRICING.lock}</Body>
|
||||
<Body>
|
||||
{PRICING.plan} {PRICING.compare}
|
||||
</Body>
|
||||
|
||||
@@ -26,6 +26,8 @@ describe('public crawler files', () => {
|
||||
}
|
||||
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>');
|
||||
@@ -48,6 +50,13 @@ describe('public crawler files', () => {
|
||||
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"');
|
||||
|
||||
+4
@@ -7,10 +7,14 @@ export const HOME: {
|
||||
memoryFeature: string;
|
||||
documentsFeature: string;
|
||||
businessFeature: string;
|
||||
earlyAdopter: string;
|
||||
roadmapImage: string;
|
||||
roadmapVision: string;
|
||||
};
|
||||
|
||||
export const PRICING: {
|
||||
price: string;
|
||||
lock: string;
|
||||
plan: string;
|
||||
compare: string;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Visible sentences for each public route. Each one is 10–25 words.
|
||||
* Wording is the existing page, split or joined. No new claims.
|
||||
*/
|
||||
const { PAGE_PATHS } = require('./canonicals');
|
||||
|
||||
@@ -13,6 +12,9 @@ function wordCount(sentence) {
|
||||
.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:
|
||||
@@ -26,10 +28,16 @@ const HOME = {
|
||||
'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.',
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user