Public homepage, pricing, privacy, changelog, and SEO (#117)
## 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, mobile  ### Landing, registration on  ### Pricing  ### Privacy  ### Changelog  ### Sign in unchanged  ## 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
No files matched your search
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -8,11 +8,48 @@
|
||||
<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 calm AI assistant. Quiet, focused AI chat — private by default. $10/month from AI ML Operations, LLC." />
|
||||
<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 calm AI assistant. Quiet, focused AI chat — private by default. $10/month." />
|
||||
<meta property="og:url" content="https://hesychia.ai/" />
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@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>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Hesychia",
|
||||
"applicationCategory": "BusinessApplication",
|
||||
"operatingSystem": "Web",
|
||||
"url": "https://hesychia.ai/",
|
||||
"description": "A calm AI assistant. Quiet, focused AI chat — private by default.",
|
||||
"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,7 +65,7 @@
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Hesychia
|
||||
|
||||
> Hesychia is a calm AI assistant. Quiet, focused AI chat — 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. Canceling a subscription does not delete documents. Account deletion is a soft-delete.
|
||||
|
||||
## 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
|
||||
- [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
|
||||
@@ -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
|
||||
@@ -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-17</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://hesychia.ai/pricing/</loc>
|
||||
<lastmod>2026-10-17</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://hesychia.ai/privacy/</loc>
|
||||
<lastmod>2026-10-17</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://hesychia.ai/changelog/</loc>
|
||||
<lastmod>2026-10-17</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>
|
||||
@@ -13,7 +13,6 @@ 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';
|
||||
@@ -26,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);
|
||||
@@ -52,6 +56,7 @@ class App extends Component {
|
||||
<AnalyticsSession />
|
||||
<ToastHost />
|
||||
<AnalyticsConsentBanner />
|
||||
<RouteMeta />
|
||||
<div className='site'>
|
||||
<main>
|
||||
<div className="main-container">
|
||||
@@ -74,9 +79,12 @@ class App extends Component {
|
||||
<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} />
|
||||
|
||||
@@ -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>
|
||||
© 2026 Hesychia | Developed by{' '}
|
||||
<a href="https://www.aimloperations.com">AI ML Operations, LLC</a>
|
||||
{PUBLIC_FOOTER_LINKS.map((link) => (
|
||||
<React.Fragment key={link.to}>
|
||||
{' · '}
|
||||
<RouterLink to="/terms_of_service/">Terms of Service</RouterLink>
|
||||
<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,220 @@
|
||||
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';
|
||||
|
||||
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: #d6dcff;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const Quiet = styled(Link)`
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
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: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.9rem 1rem;
|
||||
color: #fff;
|
||||
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: rgba(255, 255, 255, 0.8);
|
||||
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,120 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { PUBLIC_FOOTER_LINKS, PUBLIC_HEADER_LINKS } from './publicNav';
|
||||
|
||||
const Page = styled.div`
|
||||
min-height: 100vh;
|
||||
background: #07080d;
|
||||
color: #fff;
|
||||
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 rgba(255, 255, 255, 0.08);
|
||||
`;
|
||||
|
||||
const Brand = styled(Link)`
|
||||
color: #fff;
|
||||
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: rgba(255, 255, 255, 0.82);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const SignInLink = styled(Link)`
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
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 rgba(255, 255, 255, 0.08);
|
||||
padding: 1.5rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.9rem;
|
||||
`;
|
||||
|
||||
const FooterNav = styled.nav`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
`;
|
||||
|
||||
const FooterLink = styled(Link)`
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
`;
|
||||
|
||||
type PublicSiteLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const PublicSiteLayout = ({ children }: PublicSiteLayoutProps): JSX.Element => {
|
||||
return (
|
||||
<Page>
|
||||
<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}</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,84 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { axiosInstance } from '../../../axiosApi';
|
||||
|
||||
const Section = styled.section`
|
||||
margin-top: 1.5rem;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 0.95rem;
|
||||
`;
|
||||
|
||||
const Figures = styled.p`
|
||||
margin: 0 0 0.35rem;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const AsOf = styled.p`
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
`;
|
||||
|
||||
type PublicUsage = {
|
||||
account_count: number;
|
||||
document_count: number;
|
||||
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.
|
||||
* 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) return null;
|
||||
|
||||
const asOfDate = usage.as_of.slice(0, 10);
|
||||
|
||||
return (
|
||||
<Section aria-label="Usage">
|
||||
<Figures>
|
||||
{countLabel(usage.account_count, 'account', 'accounts')}
|
||||
{' · '}
|
||||
{countLabel(usage.document_count, 'document stored', 'documents stored')}
|
||||
</Figures>
|
||||
<AsOf>
|
||||
As of <time dateTime={usage.as_of}>{asOfDate}</time>
|
||||
</AsOf>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageCounts;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,25 @@
|
||||
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';
|
||||
|
||||
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,60 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
|
||||
import { CHANGELOG_ENTRIES } from './changelogEntries';
|
||||
|
||||
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 rgba(255, 255, 255, 0.1);
|
||||
padding-top: 1rem;
|
||||
`;
|
||||
|
||||
const When = styled.time`
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
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: rgba(255, 255, 255, 0.82);
|
||||
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,29 @@
|
||||
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: 'Account settings can use earlier turns in a chat as context, so you do not re-explain the same project on every message. It stays off until you turn it on.',
|
||||
},
|
||||
{
|
||||
date: '2026-08-04',
|
||||
title: 'Account deletion',
|
||||
note: 'A public page explains how to delete an account and that deletion is a soft-delete.',
|
||||
},
|
||||
{
|
||||
date: '2026-08-01',
|
||||
title: 'Documents',
|
||||
note: 'Upload documents and search them from chat. Personal and company libraries stay separate.',
|
||||
},
|
||||
{
|
||||
date: '2026-08-01',
|
||||
title: 'Subscriptions',
|
||||
note: 'Upgrade, change, or cancel from the account page. The public price is $10 a month.',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,105 @@
|
||||
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';
|
||||
|
||||
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.getByText(/Business accounts/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/');
|
||||
|
||||
expect(await screen.findByText(/3 accounts/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/12 documents stored/)).toBeInTheDocument();
|
||||
expect(screen.getByText('2026-10-17')).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,126 @@
|
||||
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';
|
||||
|
||||
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: rgba(255, 255, 255, 0.92);
|
||||
`;
|
||||
|
||||
const Memory = styled.p`
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 1.5rem;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
`;
|
||||
|
||||
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: rgba(255, 255, 255, 0.82);
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const TextLink = styled(Link)`
|
||||
color: #c9d4ff;
|
||||
`;
|
||||
|
||||
const Features = styled.section`
|
||||
margin-top: 3rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
const Feature = styled.article`
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
`;
|
||||
|
||||
const FeatureTitle = styled.h2`
|
||||
font-size: 1.15rem;
|
||||
margin: 0 0 0.4rem;
|
||||
`;
|
||||
|
||||
const FeatureBody = styled.p`
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const LandingPage = (): JSX.Element => {
|
||||
return (
|
||||
<PublicSiteLayout>
|
||||
<Hero>
|
||||
<Mark src={hesychiaMark} alt="Hesychia logo" />
|
||||
<Title>Hesychia is a calm AI assistant.</Title>
|
||||
<Lede>Quiet, focused AI chat — private by default.</Lede>
|
||||
<Memory>
|
||||
It remembers what you told it — and your documents — so you never start over.
|
||||
Conversation memory is a setting you control.
|
||||
</Memory>
|
||||
<GetStarted source="landing" />
|
||||
<Privacy aria-label="Privacy">
|
||||
<h2>What we do with your data</h2>
|
||||
<PrivacyList>
|
||||
<li>We store your account, your conversations, and the documents you upload.</li>
|
||||
<li>We do not sell your data, and we do not use advertising cookies.</li>
|
||||
<li>
|
||||
Canceling does not delete documents. They stay until you delete them or delete the
|
||||
account. Account deletion is a soft-delete.
|
||||
</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>
|
||||
It remembers what you told it — and your documents — so you never start over.
|
||||
</FeatureBody>
|
||||
</Feature>
|
||||
<Feature>
|
||||
<FeatureTitle>Your documents, stored and searchable</FeatureTitle>
|
||||
<FeatureBody>Answers grounded in your own files.</FeatureBody>
|
||||
</Feature>
|
||||
<Feature>
|
||||
<FeatureTitle>Business accounts</FeatureTitle>
|
||||
<FeatureBody>Simple management for your team.</FeatureBody>
|
||||
</Feature>
|
||||
</Features>
|
||||
</PublicSiteLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import PricingPage from './PricingPage';
|
||||
|
||||
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\. No ads, no upsells, one price/)).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Join the waitlist' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'See pricing' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
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';
|
||||
|
||||
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: rgba(255, 255, 255, 0.82);
|
||||
line-height: 1.5;
|
||||
max-width: 36rem;
|
||||
`;
|
||||
|
||||
const Back = styled(Link)`
|
||||
color: #c9d4ff;
|
||||
`;
|
||||
|
||||
const PricingPage = (): JSX.Element => {
|
||||
return (
|
||||
<PublicSiteLayout>
|
||||
<Title>Pricing</Title>
|
||||
<Price>$10/month, flat. No ads, no upsells, one price.</Price>
|
||||
<Body>
|
||||
One public plan. Quiet, focused AI chat, your documents, and business accounts for your
|
||||
team. Hard to justify $20 a month — this is $10.
|
||||
</Body>
|
||||
<GetStarted source="pricing" idPrefix="pricing-waitlist" showPricingLink={false} />
|
||||
<p>
|
||||
<Back to="/">Back to Hesychia</Back>
|
||||
</p>
|
||||
</PublicSiteLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPage;
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import PrivacyPage from './PrivacyPage';
|
||||
|
||||
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,70 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
|
||||
|
||||
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: rgba(255, 255, 255, 0.82);
|
||||
line-height: 1.55;
|
||||
`;
|
||||
|
||||
const TextLink = styled(Link)`
|
||||
color: #c9d4ff;
|
||||
`;
|
||||
|
||||
const PrivacyPage = (): JSX.Element => {
|
||||
return (
|
||||
<PublicSiteLayout>
|
||||
<Title>Privacy</Title>
|
||||
<Section>
|
||||
<Heading>What is stored</Heading>
|
||||
<Body>
|
||||
Your account (email, name, and an optional company), your conversations, and the
|
||||
documents you upload so answers can use your files. Optional product analytics, if you
|
||||
accept them, record actions such as sign-in and checkout. They do not include message or
|
||||
document contents.
|
||||
</Body>
|
||||
</Section>
|
||||
<Section>
|
||||
<Heading>What we do not do</Heading>
|
||||
<Body>
|
||||
We do not sell your information. We do not use advertising cookies. Information you enter
|
||||
is not shared with third parties without your consent, except to operate the service or
|
||||
when the law requires it. 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>
|
||||
Canceling stops paid access at the end of the current period. It does not delete
|
||||
uploaded documents. Documents stay in your workspace until you delete them, or until you
|
||||
delete the account. Account deletion is a soft-delete: profile details, conversations,
|
||||
uploaded documents, and authentication records may remain for administration, security,
|
||||
and audit. There is no automatic hard purge. See{' '}
|
||||
<TextLink to="/account-deletion/">how account deletion works</TextLink>.
|
||||
</Body>
|
||||
</Section>
|
||||
</PublicSiteLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPage;
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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).not.toContain('<html');
|
||||
|
||||
expect(html).toContain('<title>Hesychia — quiet, focused AI chat</title>');
|
||||
expect(html).toContain('rel="canonical" href="https://hesychia.ai/"');
|
||||
expect(html).toContain('"@type": "Organization"');
|
||||
expect(html).toContain('"@type": "SoftwareApplication"');
|
||||
expect(html).toContain('"price": "10.00"');
|
||||
expect(html).toContain('AI ML Operations, LLC');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,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 +56,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];
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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',
|
||||
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: 'Pricing — Hesychia',
|
||||
canonical: 'https://hesychia.ai/pricing/',
|
||||
});
|
||||
expect(metaForPath('/privacy/', false)?.canonical).toBe('https://hesychia.ai/privacy/');
|
||||
expect(metaForPath('/changelog/', false)?.title).toBe('Changelog — Hesychia');
|
||||
expect(metaForPath('/signin/', false)?.canonical).toBeNull();
|
||||
});
|
||||
|
||||
it('does not invent a title for an unknown path', () => {
|
||||
expect(metaForPath('/not-a-page', false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyRouteMeta', () => {
|
||||
afterEach(() => {
|
||||
document.title = '';
|
||||
document.querySelector('link[rel="canonical"]')?.remove();
|
||||
});
|
||||
|
||||
it('sets the title and canonical', () => {
|
||||
applyRouteMeta({ title: 'Pricing — Hesychia', canonical: 'https://hesychia.ai/pricing/' });
|
||||
expect(document.title).toBe('Pricing — Hesychia');
|
||||
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
|
||||
'https://hesychia.ai/pricing/',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a canonical when the route should not have one', () => {
|
||||
applyRouteMeta({ title: 'Pricing — Hesychia', canonical: 'https://hesychia.ai/pricing/' });
|
||||
applyRouteMeta({ title: 'Sign in — Hesychia', canonical: null });
|
||||
expect(document.title).toBe('Sign in — Hesychia');
|
||||
expect(document.querySelector('link[rel="canonical"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
export type RouteMeta = {
|
||||
title: string;
|
||||
/** Absolute canonical URL. Null means this path should not advertise a canonical. */
|
||||
canonical: string | null;
|
||||
};
|
||||
|
||||
const ORIGIN = 'https://hesychia.ai';
|
||||
|
||||
const PUBLIC_PAGES: Record<string, RouteMeta> = {
|
||||
'/': {
|
||||
title: 'Hesychia — quiet, focused AI chat',
|
||||
canonical: `${ORIGIN}/`,
|
||||
},
|
||||
'/pricing/': {
|
||||
title: 'Pricing — Hesychia',
|
||||
canonical: `${ORIGIN}/pricing/`,
|
||||
},
|
||||
'/privacy/': {
|
||||
title: 'Privacy — Hesychia',
|
||||
canonical: `${ORIGIN}/privacy/`,
|
||||
},
|
||||
'/changelog/': {
|
||||
title: 'Changelog — Hesychia',
|
||||
canonical: `${ORIGIN}/changelog/`,
|
||||
},
|
||||
'/terms_of_service/': {
|
||||
title: 'Terms of Service — Hesychia',
|
||||
canonical: `${ORIGIN}/terms_of_service/`,
|
||||
},
|
||||
'/legal/terms-of-service': {
|
||||
title: 'Terms of Service — Hesychia',
|
||||
canonical: `${ORIGIN}/legal/terms-of-service`,
|
||||
},
|
||||
'/account-deletion/': {
|
||||
title: 'Account deletion — Hesychia',
|
||||
canonical: `${ORIGIN}/account-deletion/`,
|
||||
},
|
||||
'/signin/': {
|
||||
title: 'Sign in — Hesychia',
|
||||
canonical: null,
|
||||
},
|
||||
'/signup/': {
|
||||
title: 'Create account — Hesychia',
|
||||
canonical: null,
|
||||
},
|
||||
'/password_reset/': {
|
||||
title: 'Reset password — Hesychia',
|
||||
canonical: null,
|
||||
},
|
||||
'/account/': {
|
||||
title: 'Account — Hesychia',
|
||||
canonical: null,
|
||||
},
|
||||
'/document_storage': {
|
||||
title: 'Documents — Hesychia',
|
||||
canonical: null,
|
||||
},
|
||||
'/feedback/': {
|
||||
title: 'Feedback — Hesychia',
|
||||
canonical: null,
|
||||
},
|
||||
'/analytics/': {
|
||||
title: 'Analytics — Hesychia',
|
||||
canonical: null,
|
||||
},
|
||||
};
|
||||
|
||||
/** 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', canonical: `${ORIGIN}/` };
|
||||
}
|
||||
return PUBLIC_PAGES[pathname] ?? null;
|
||||
}
|
||||
|
||||
export function applyRouteMeta(meta: RouteMeta | null): void {
|
||||
if (!meta || typeof document === 'undefined') return;
|
||||
|
||||
document.title = meta.title;
|
||||
|
||||
const existing = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
||||
if (!meta.canonical) {
|
||||
existing?.parentNode?.removeChild(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const link = existing ?? document.createElement('link');
|
||||
link.rel = 'canonical';
|
||||
link.href = meta.canonical;
|
||||
if (!existing) {
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||