diff --git a/llm-fe/docs/ux/changelog.png b/llm-fe/docs/ux/changelog.png new file mode 100644 index 0000000..e12dc6f Binary files /dev/null and b/llm-fe/docs/ux/changelog.png differ diff --git a/llm-fe/docs/ux/landing-desktop.png b/llm-fe/docs/ux/landing-desktop.png new file mode 100644 index 0000000..3fc84c6 Binary files /dev/null and b/llm-fe/docs/ux/landing-desktop.png differ diff --git a/llm-fe/docs/ux/landing-mobile.png b/llm-fe/docs/ux/landing-mobile.png new file mode 100644 index 0000000..6bd3339 Binary files /dev/null and b/llm-fe/docs/ux/landing-mobile.png differ diff --git a/llm-fe/docs/ux/landing-signup.png b/llm-fe/docs/ux/landing-signup.png new file mode 100644 index 0000000..5d30af7 Binary files /dev/null and b/llm-fe/docs/ux/landing-signup.png differ diff --git a/llm-fe/docs/ux/pricing.png b/llm-fe/docs/ux/pricing.png new file mode 100644 index 0000000..a4d61a1 Binary files /dev/null and b/llm-fe/docs/ux/pricing.png differ diff --git a/llm-fe/docs/ux/privacy.png b/llm-fe/docs/ux/privacy.png new file mode 100644 index 0000000..d439b90 Binary files /dev/null and b/llm-fe/docs/ux/privacy.png differ diff --git a/llm-fe/docs/ux/signin.png b/llm-fe/docs/ux/signin.png new file mode 100644 index 0000000..8209222 Binary files /dev/null and b/llm-fe/docs/ux/signin.png differ diff --git a/llm-fe/public/index.html b/llm-fe/public/index.html index 9e6cdbb..375975a 100644 --- a/llm-fe/public/index.html +++ b/llm-fe/public/index.html @@ -8,11 +8,48 @@ - + - - - + + + + + + + - Hesychia + Hesychia — quiet, focused AI chat diff --git a/llm-fe/public/llms.txt b/llm-fe/public/llms.txt new file mode 100644 index 0000000..271f584 --- /dev/null +++ b/llm-fe/public/llms.txt @@ -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 diff --git a/llm-fe/public/robots.txt b/llm-fe/public/robots.txt index e9e57dc..730f452 100644 --- a/llm-fe/public/robots.txt +++ b/llm-fe/public/robots.txt @@ -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 diff --git a/llm-fe/public/sitemap.xml b/llm-fe/public/sitemap.xml new file mode 100644 index 0000000..69ebe6b --- /dev/null +++ b/llm-fe/public/sitemap.xml @@ -0,0 +1,27 @@ + + + + https://hesychia.ai/ + 2026-10-17 + + + https://hesychia.ai/pricing/ + 2026-10-17 + + + https://hesychia.ai/privacy/ + 2026-10-17 + + + https://hesychia.ai/changelog/ + 2026-10-17 + + + https://hesychia.ai/terms_of_service/ + 2026-07-27 + + + https://hesychia.ai/account-deletion/ + 2026-08-04 + + diff --git a/llm-fe/src/App.tsx b/llm-fe/src/App.tsx index 2f94967..2ed64bf 100644 --- a/llm-fe/src/App.tsx +++ b/llm-fe/src/App.tsx @@ -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 { +
@@ -74,9 +79,12 @@ class App extends Component { {/* Play Console account-deletion URL (#103) — must stay public */} + + + + }> - diff --git a/llm-fe/src/llm-fe/components/Footer/Footer.test.tsx b/llm-fe/src/llm-fe/components/Footer/Footer.test.tsx index 59b8d6f..ef496cc 100644 --- a/llm-fe/src/llm-fe/components/Footer/Footer.test.tsx +++ b/llm-fe/src/llm-fe/components/Footer/Footer.test.tsx @@ -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/'); }); }); diff --git a/llm-fe/src/llm-fe/components/Footer/Footer.tsx b/llm-fe/src/llm-fe/components/Footer/Footer.tsx index da760ce..00d2263 100644 --- a/llm-fe/src/llm-fe/components/Footer/Footer.tsx +++ b/llm-fe/src/llm-fe/components/Footer/Footer.tsx @@ -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 => { © 2026 Hesychia | Developed by{' '} AI ML Operations, LLC - {' · '} - Terms of Service + {PUBLIC_FOOTER_LINKS.map((link) => ( + + {' · '} + {link.label} + + ))}
); diff --git a/llm-fe/src/llm-fe/components/HomeRoute/HomeRoute.test.tsx b/llm-fe/src/llm-fe/components/HomeRoute/HomeRoute.test.tsx new file mode 100644 index 0000000..487120e --- /dev/null +++ b/llm-fe/src/llm-fe/components/HomeRoute/HomeRoute.test.tsx @@ -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', () => () =>
Chat app
); +jest.mock('../../pages/Landing/LandingPage', () => () =>

Hesychia is a calm AI assistant.

); + +const renderHome = (authenticated: boolean, loading: boolean) => { + render( + + {}, + needsNewPassword: false, + setNeedsNewPassword: () => {}, + loading, + }} + > + + + , + ); +}; + +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(); + }); +}); diff --git a/llm-fe/src/llm-fe/components/HomeRoute/HomeRoute.tsx b/llm-fe/src/llm-fe/components/HomeRoute/HomeRoute.tsx new file mode 100644 index 0000000..4e3d842 --- /dev/null +++ b/llm-fe/src/llm-fe/components/HomeRoute/HomeRoute.tsx @@ -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
Loading
; + } + + if (authenticated) { + return ; + } + + return ; +}; + +export default HomeRoute; diff --git a/llm-fe/src/llm-fe/components/PublicSite/GetStarted.tsx b/llm-fe/src/llm-fe/components/PublicSite/GetStarted.tsx new file mode 100644 index 0000000..9305c01 --- /dev/null +++ b/llm-fe/src/llm-fe/components/PublicSite/GetStarted.tsx @@ -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(null); + const [email, setEmail] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(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) => { + 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 Checking signup…; + } + + if (registrationEnabled) { + return ( + + + + Create account + + {showPricingLink && ( + trackEvent(AnalyticsEvents.LANDING_PRICING_CTA, { source })} + > + See pricing + + )} + + Sign in + + ); + } + + return ( + +
+ + setEmail(event.target.value)} + required + /> + + {submitting ? 'Joining…' : 'Join the waitlist'} + +
+ {message && {message}} + {error && {error}} + + {showPricingLink && ( + trackEvent(AnalyticsEvents.LANDING_PRICING_CTA, { source })} + > + See pricing + + )} + Sign in + +
+ ); +}; + +export default GetStarted; diff --git a/llm-fe/src/llm-fe/components/PublicSite/PublicSiteLayout.tsx b/llm-fe/src/llm-fe/components/PublicSite/PublicSiteLayout.tsx new file mode 100644 index 0000000..861958f --- /dev/null +++ b/llm-fe/src/llm-fe/components/PublicSite/PublicSiteLayout.tsx @@ -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 ( + +
+ Hesychia + +
+
{children}
+ + + {PUBLIC_FOOTER_LINKS.map((link) => ( + + {link.label} + + ))} + +
+ © 2026 Hesychia · Developed by{' '} + AI ML Operations, LLC +
+
+
+ ); +}; + +export default PublicSiteLayout; diff --git a/llm-fe/src/llm-fe/components/PublicSite/UsageCounts.tsx b/llm-fe/src/llm-fe/components/PublicSite/UsageCounts.tsx new file mode 100644 index 0000000..42ecf40 --- /dev/null +++ b/llm-fe/src/llm-fe/components/PublicSite/UsageCounts.tsx @@ -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(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 ( +
+ + {countLabel(usage.account_count, 'account', 'accounts')} + {' · '} + {countLabel(usage.document_count, 'document stored', 'documents stored')} + + + As of + +
+ ); +}; + +export default UsageCounts; diff --git a/llm-fe/src/llm-fe/components/PublicSite/publicNav.ts b/llm-fe/src/llm-fe/components/PublicSite/publicNav.ts new file mode 100644 index 0000000..565bfcc --- /dev/null +++ b/llm-fe/src/llm-fe/components/PublicSite/publicNav.ts @@ -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' }, +]; diff --git a/llm-fe/src/llm-fe/components/RouteMeta/RouteMeta.tsx b/llm-fe/src/llm-fe/components/RouteMeta/RouteMeta.tsx new file mode 100644 index 0000000..c6fc24a --- /dev/null +++ b/llm-fe/src/llm-fe/components/RouteMeta/RouteMeta.tsx @@ -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; diff --git a/llm-fe/src/llm-fe/pages/Changelog/ChangelogPage.test.tsx b/llm-fe/src/llm-fe/pages/Changelog/ChangelogPage.test.tsx new file mode 100644 index 0000000..6d00de5 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/Changelog/ChangelogPage.test.tsx @@ -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( + + + , + ); + + 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); + }); +}); diff --git a/llm-fe/src/llm-fe/pages/Changelog/ChangelogPage.tsx b/llm-fe/src/llm-fe/pages/Changelog/ChangelogPage.tsx new file mode 100644 index 0000000..11037c1 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/Changelog/ChangelogPage.tsx @@ -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 ( + + Changelog + + {CHANGELOG_ENTRIES.map((entry) => ( + + {entry.date} + {entry.title} + {entry.note} + + ))} + + + ); +}; + +export default ChangelogPage; diff --git a/llm-fe/src/llm-fe/pages/Changelog/changelogEntries.ts b/llm-fe/src/llm-fe/pages/Changelog/changelogEntries.ts new file mode 100644 index 0000000..dd6bf56 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/Changelog/changelogEntries.ts @@ -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.', + }, +]; diff --git a/llm-fe/src/llm-fe/pages/Landing/LandingPage.test.tsx b/llm-fe/src/llm-fe/pages/Landing/LandingPage.test.tsx new file mode 100644 index 0000000..622b6e4 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/Landing/LandingPage.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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."); + }); +}); diff --git a/llm-fe/src/llm-fe/pages/Landing/LandingPage.tsx b/llm-fe/src/llm-fe/pages/Landing/LandingPage.tsx new file mode 100644 index 0000000..958a380 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/Landing/LandingPage.tsx @@ -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 ( + + + + Hesychia is a calm AI assistant. + Quiet, focused AI chat — private by default. + + It remembers what you told it — and your documents — so you never start over. + Conversation memory is a setting you control. + + + +

What we do with your data

+ +
  • We store your account, your conversations, and the documents you upload.
  • +
  • We do not sell your data, and we do not use advertising cookies.
  • +
  • + Canceling does not delete documents. They stay until you delete them or delete the + account. Account deletion is a soft-delete. +
  • +
    + Privacy details +
    + +
    + + + Memory that stays with the work + + It remembers what you told it — and your documents — so you never start over. + + + + Your documents, stored and searchable + Answers grounded in your own files. + + + Business accounts + Simple management for your team. + + +
    + ); +}; + +export default LandingPage; diff --git a/llm-fe/src/llm-fe/pages/Pricing/PricingPage.test.tsx b/llm-fe/src/llm-fe/pages/Pricing/PricingPage.test.tsx new file mode 100644 index 0000000..616a87d --- /dev/null +++ b/llm-fe/src/llm-fe/pages/Pricing/PricingPage.test.tsx @@ -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( + + + , + ); + + 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(); + }); +}); diff --git a/llm-fe/src/llm-fe/pages/Pricing/PricingPage.tsx b/llm-fe/src/llm-fe/pages/Pricing/PricingPage.tsx new file mode 100644 index 0000000..286977d --- /dev/null +++ b/llm-fe/src/llm-fe/pages/Pricing/PricingPage.tsx @@ -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 ( + + Pricing + $10/month, flat. No ads, no upsells, one price. + + One public plan. Quiet, focused AI chat, your documents, and business accounts for your + team. Hard to justify $20 a month — this is $10. + + +

    + Back to Hesychia +

    +
    + ); +}; + +export default PricingPage; diff --git a/llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.test.tsx b/llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.test.tsx new file mode 100644 index 0000000..d278039 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.test.tsx @@ -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( + + + , + ); + + 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/', + ); + }); +}); diff --git a/llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.tsx b/llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.tsx new file mode 100644 index 0000000..ee80fab --- /dev/null +++ b/llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.tsx @@ -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 ( + + Privacy +
    + What is stored + + 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. + +
    +
    + What we do not do + + 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{' '} + Terms of Service and + the analytics section are the + binding description. + +
    +
    + If you cancel + + 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{' '} + how account deletion works. + +
    +
    + ); +}; + +export default PrivacyPage; diff --git a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx index 83fe8f4..0396e47 100644 --- a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx +++ b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx @@ -264,7 +264,7 @@ const SignIn = (): JSX.Element => { - + Hesychia.ai Sign In diff --git a/llm-fe/src/llm-fe/publicCrawlerFiles.test.ts b/llm-fe/src/llm-fe/publicCrawlerFiles.test.ts new file mode 100644 index 0000000..6d5ea3b --- /dev/null +++ b/llm-fe/src/llm-fe/publicCrawlerFiles.test.ts @@ -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('Hesychia — quiet, focused AI chat'); + 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'); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/analytics.ts b/llm-fe/src/llm-fe/utils/analytics.ts index ba021e1..6913afb 100644 --- a/llm-fe/src/llm-fe/utils/analytics.ts +++ b/llm-fe/src/llm-fe/utils/analytics.ts @@ -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]; diff --git a/llm-fe/src/llm-fe/utils/routeMeta.test.ts b/llm-fe/src/llm-fe/utils/routeMeta.test.ts new file mode 100644 index 0000000..1041445 --- /dev/null +++ b/llm-fe/src/llm-fe/utils/routeMeta.test.ts @@ -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(); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/routeMeta.ts b/llm-fe/src/llm-fe/utils/routeMeta.ts new file mode 100644 index 0000000..d7bd136 --- /dev/null +++ b/llm-fe/src/llm-fe/utils/routeMeta.ts @@ -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 = { + '/': { + 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('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); + } +}