Add a public homepage so visitors can tell what Hesychia is.
Unit Tests / test (pull_request) Successful in 36s
Logged-out / is the marketing site. Signed-in / still opens chat. Closes #113, #114, #115, and #116. Co-authored-by: Cursor <cursoragent@cursor.com>
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" />
|
<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="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<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 name="application-name" content="Hesychia" />
|
||||||
<meta property="og:title" content="Hesychia" />
|
<link rel="canonical" href="https://hesychia.ai/" />
|
||||||
<meta property="og:description" content="Hesychia — quiet, focused AI chat at hesychia.ai" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://hesychia.ai" />
|
<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" />
|
<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
|
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.
|
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`.
|
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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
User-agent: *
|
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 PublicTermsOfServicePage from './llm-fe/pages/TermsOfService/PublicTermsOfServicePage';
|
||||||
import AccountDeletionPage from './llm-fe/pages/AccountDeletion/AccountDeletionPage';
|
import AccountDeletionPage from './llm-fe/pages/AccountDeletion/AccountDeletionPage';
|
||||||
import SetPassword from './llm-fe/components/SetPassword/SetPassword';
|
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 FeedbackPage2 from './llm-fe/pages/FeedbackPage2/FeedbackPage2';
|
||||||
import DocumentStoragePage from './llm-fe/pages/DocumentStoragePage/DocumentStoragePage';
|
import DocumentStoragePage from './llm-fe/pages/DocumentStoragePage/DocumentStoragePage';
|
||||||
import Account2 from './llm-fe/pages/Account2/Account2';
|
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 AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
|
||||||
import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary';
|
import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary';
|
||||||
import ToastHost from './llm-fe/components/ToastHost/ToastHost';
|
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 ProtectedRoutes = () => {
|
||||||
const { authenticated, loading } = useContext(AuthContext);
|
const { authenticated, loading } = useContext(AuthContext);
|
||||||
@@ -52,6 +56,7 @@ class App extends Component {
|
|||||||
<AnalyticsSession />
|
<AnalyticsSession />
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
<AnalyticsConsentBanner />
|
<AnalyticsConsentBanner />
|
||||||
|
<RouteMeta />
|
||||||
<div className='site'>
|
<div className='site'>
|
||||||
<main>
|
<main>
|
||||||
<div className="main-container">
|
<div className="main-container">
|
||||||
@@ -74,9 +79,12 @@ class App extends Component {
|
|||||||
<Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} />
|
<Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} />
|
||||||
{/* Play Console account-deletion URL (#103) — must stay public */}
|
{/* Play Console account-deletion URL (#103) — must stay public */}
|
||||||
<Route path={"/account-deletion/"} Component={AccountDeletionPage} />
|
<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 element={<ProtectedRoutes />}>
|
||||||
<Route path={"/"} index={true} Component={AsyncDashboard2} />
|
|
||||||
<Route path={"/account/"} Component={Account2} />
|
<Route path={"/account/"} Component={Account2} />
|
||||||
<Route path={"/document_storage"} Component={DocumentStoragePage} />
|
<Route path={"/document_storage"} Component={DocumentStoragePage} />
|
||||||
<Route path={"/feedback/"} Component={FeedbackPage2} />
|
<Route path={"/feedback/"} Component={FeedbackPage2} />
|
||||||
|
|||||||
@@ -25,5 +25,8 @@ describe('Footer', () => {
|
|||||||
'href',
|
'href',
|
||||||
'/terms_of_service/',
|
'/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 React from 'react';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import MDTypography from '../../ui-kit/components/MDTypography';
|
import MDTypography from '../../ui-kit/components/MDTypography';
|
||||||
|
import { PUBLIC_FOOTER_LINKS } from '../PublicSite/publicNav';
|
||||||
|
|
||||||
const Footer = (): JSX.Element => {
|
const Footer = (): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
@@ -8,8 +9,12 @@ const Footer = (): JSX.Element => {
|
|||||||
<MDTypography>
|
<MDTypography>
|
||||||
© 2026 Hesychia | Developed by{' '}
|
© 2026 Hesychia | Developed by{' '}
|
||||||
<a href="https://www.aimloperations.com">AI ML Operations, LLC</a>
|
<a href="https://www.aimloperations.com">AI ML Operations, LLC</a>
|
||||||
{' · '}
|
{PUBLIC_FOOTER_LINKS.map((link) => (
|
||||||
<RouterLink to="/terms_of_service/">Terms of Service</RouterLink>
|
<React.Fragment key={link.to}>
|
||||||
|
{' · '}
|
||||||
|
<RouterLink to={link.to}>{link.label}</RouterLink>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
</MDTypography>
|
</MDTypography>
|
||||||
</div>
|
</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>
|
<ContentWrapper>
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<BrandBlock>
|
<BrandBlock>
|
||||||
<BrandMark src={hesychiaMark} alt="" />
|
<BrandMark src={hesychiaMark} alt="Hesychia logo" />
|
||||||
<BrandWordmark>Hesychia.ai</BrandWordmark>
|
<BrandWordmark>Hesychia.ai</BrandWordmark>
|
||||||
</BrandBlock>
|
</BrandBlock>
|
||||||
<CardTitle>Sign In</CardTitle>
|
<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) |
|
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
|
||||||
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
|
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
|
||||||
* | Activity Stage Completed | Live activity status stage rolled over (#96) |
|
* | 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 = {
|
export const AnalyticsEvents = {
|
||||||
LOGIN_SUCCESS: 'Login Success',
|
LOGIN_SUCCESS: 'Login Success',
|
||||||
@@ -53,6 +56,9 @@ export const AnalyticsEvents = {
|
|||||||
MESSAGE_RATING_REASON: 'Message Rating Reason',
|
MESSAGE_RATING_REASON: 'Message Rating Reason',
|
||||||
MESSAGE_EXPORTED: 'Message Exported',
|
MESSAGE_EXPORTED: 'Message Exported',
|
||||||
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
|
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
|
||||||
|
LANDING_PRIMARY_CTA: 'Landing Primary CTA',
|
||||||
|
LANDING_PRICING_CTA: 'Landing Pricing CTA',
|
||||||
|
WAITLIST_JOINED: 'Waitlist Joined',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||