Product page: lifetime price, rounded usage, roadmap (#126)
## 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
This commit was merged in pull request #126.
This commit is contained in:
1 parent
7a8f413483
commit
36341f6e72
12 files changed
+160
-14
No files matched your search
@@ -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