Logged-out home stays the product page #120

Merged
westfarn merged 1 commits from feat/public-home-particles into master 2026-10-19 01:09:44 -07:00
15 changed files with 149 additions and 38 deletions
Showing only changes of commit 6133e0dce8 - Show all commits

No files matched your search

+14 -5
View File
@@ -11,6 +11,7 @@ import {
isTokenNotValidError,
} from "./llm-fe/auth/jwtHelpers";
import { redirectToAppPath } from "./llm-fe/platform/nativePlatform";
import { shouldRedirectToSignIn } from "./llm-fe/auth/authRedirect";
const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
@@ -97,8 +98,10 @@ axiosInstance.interceptors.response.use(
if (error.response.status === 401 && isRefreshCall) {
await clearTokens();
applyAccessToken(null);
// Hash-safe on Capacitor (#24); plain path on web BrowserRouter.
redirectToAppPath("/signin/");
// Stay on the product page. Only bounce protected app routes.
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
return Promise.reject(error);
}
@@ -109,7 +112,9 @@ axiosInstance.interceptors.response.use(
if (!refresh_token) {
await clearTokens();
applyAccessToken(null);
redirectToAppPath("/signin/");
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
return Promise.reject(error);
}
@@ -127,14 +132,18 @@ axiosInstance.interceptors.response.use(
} catch (err) {
await clearTokens();
applyAccessToken(null);
redirectToAppPath("/signin/");
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
return Promise.reject(err);
}
}
await clearTokens();
applyAccessToken(null);
redirectToAppPath("/signin/");
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
}
return Promise.reject(error);
@@ -0,0 +1,15 @@
import { shouldRedirectToSignIn } from './authRedirect';
describe('shouldRedirectToSignIn', () => {
it('keeps a logged-out visitor on the product page', () => {
expect(shouldRedirectToSignIn('/public/usage/', '/')).toBe(false);
expect(shouldRedirectToSignIn('/public/settings/', '/pricing/')).toBe(false);
expect(shouldRedirectToSignIn('/user/get/', '/')).toBe(false);
expect(shouldRedirectToSignIn('/user/get/', '/changelog/')).toBe(false);
});
it('still sends an expired session on the app to sign in', () => {
expect(shouldRedirectToSignIn('/user/get/', '/account/')).toBe(true);
expect(shouldRedirectToSignIn('/token/refresh/', '/document_storage')).toBe(true);
});
});
+38
View File
@@ -0,0 +1,38 @@
/** Public marketing routes. An auth failure here must not replace the page with Sign In. */
const PUBLIC_PATHS = new Set([
"/",
"/pricing",
"/privacy",
"/changelog",
"/terms_of_service",
"/legal/terms-of-service",
"/account-deletion",
"/signin",
"/signup",
"/password_reset",
"/password_reset_confirmation",
"/set_password",
]);
export function normalizeAppPath(pathname: string | null | undefined): string {
if (!pathname || pathname === "/") return "/";
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
export function isPublicApiRequest(requestUrl: string | null | undefined): boolean {
return String(requestUrl || "").includes("/public/");
}
export function isPublicAppPath(pathname: string | null | undefined): boolean {
return PUBLIC_PATHS.has(normalizeAppPath(pathname));
}
export function shouldRedirectToSignIn(
requestUrl: string | null | undefined,
pathname: string | null | undefined,
): boolean {
if (isPublicApiRequest(requestUrl)) return false;
if (isPublicAppPath(pathname)) return false;
return true;
}
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
import { publicAccent, publicCard, publicLine, publicText } from './publicColors';
const Stack = styled.div`
display: flex;
@@ -34,12 +35,13 @@ const Primary = styled(Link)`
`;
const Secondary = styled(Link)`
color: #d6dcff;
color: ${publicAccent};
font-weight: 600;
`;
const Quiet = styled(Link)`
color: rgba(255, 255, 255, 0.72);
color: ${publicText};
opacity: 0.75;
font-size: 0.95rem;
`;
@@ -63,11 +65,11 @@ const Label = styled.label`
const Input = styled.input`
flex: 1 1 14rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.16);
background: ${publicCard};
border: 1px solid ${publicLine};
border-radius: 0.5rem;
padding: 0.9rem 1rem;
color: #fff;
color: ${publicText};
font-size: 1rem;
`;
@@ -89,7 +91,7 @@ const Submit = styled.button`
const Note = styled.p`
margin: 0;
color: rgba(255, 255, 255, 0.8);
color: ${publicText};
font-size: 0.95rem;
`;
@@ -1,12 +1,21 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import ParticleBackground from '../ParticleBackground/ParticleBackground';
import { PUBLIC_FOOTER_LINKS, PUBLIC_HEADER_LINKS } from './publicNav';
const text = ({ theme }: { theme?: { colors?: { text?: string } } }) =>
theme?.colors?.text ?? '#344767';
const line = ({ theme }: { theme?: { colors?: { cardBorder?: string } } }) =>
theme?.colors?.cardBorder ?? 'rgba(0, 0, 0, 0.08)';
const Page = styled.div`
position: relative;
z-index: 1;
min-height: 100vh;
background: #07080d;
color: #fff;
background: transparent;
color: ${text};
font-family: Inter, sans-serif;
display: flex;
flex-direction: column;
@@ -19,11 +28,11 @@ const Header = styled.header`
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid ${line};
`;
const Brand = styled(Link)`
color: #fff;
color: ${text};
font-weight: 700;
font-size: 1.15rem;
text-decoration: none;
@@ -38,19 +47,20 @@ const Nav = styled.nav`
`;
const NavLink = styled(Link)`
color: rgba(255, 255, 255, 0.82);
color: ${text};
text-decoration: none;
font-size: 0.95rem;
opacity: 0.85;
&:hover {
color: #fff;
opacity: 1;
}
`;
const SignInLink = styled(Link)`
color: #fff;
color: ${text};
text-decoration: none;
border: 1px solid rgba(255, 255, 255, 0.28);
border: 1px solid ${line};
border-radius: 0.5rem;
padding: 0.45rem 0.8rem;
font-size: 0.95rem;
@@ -64,10 +74,11 @@ const Main = styled.main`
`;
const FooterBar = styled.footer`
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid ${line};
padding: 1.5rem;
color: rgba(255, 255, 255, 0.7);
color: ${text};
font-size: 0.9rem;
opacity: 0.85;
`;
const FooterNav = styled.nav`
@@ -78,7 +89,7 @@ const FooterNav = styled.nav`
`;
const FooterLink = styled(Link)`
color: rgba(255, 255, 255, 0.82);
color: ${text};
`;
type PublicSiteLayoutProps = {
@@ -88,6 +99,7 @@ type PublicSiteLayoutProps = {
const PublicSiteLayout = ({ children }: PublicSiteLayoutProps): JSX.Element => {
return (
<Page>
<ParticleBackground />
<Header>
<Brand to="/">Hesychia</Brand>
<Nav aria-label="Primary">
@@ -1,10 +1,11 @@
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import { publicText } from './publicColors';
const Section = styled.section`
margin-top: 1.5rem;
color: rgba(255, 255, 255, 0.86);
color: ${publicText};
font-size: 0.95rem;
`;
@@ -15,7 +16,8 @@ const Figures = styled.p`
const AsOf = styled.p`
margin: 0;
color: rgba(255, 255, 255, 0.62);
color: ${publicText};
opacity: 0.7;
`;
type PublicUsage = {
@@ -0,0 +1,17 @@
type PublicTheme = {
colors?: {
text?: string;
cardBackground?: string;
cardBorder?: string;
};
};
export const publicText = ({ theme }: { theme?: PublicTheme }) => theme?.colors?.text ?? '#344767';
export const publicCard = ({ theme }: { theme?: PublicTheme }) =>
theme?.colors?.cardBackground ?? 'rgba(255, 255, 255, 0.72)';
export const publicLine = ({ theme }: { theme?: PublicTheme }) =>
theme?.colors?.cardBorder ?? 'rgba(0, 0, 0, 0.08)';
export const publicAccent = '#667eea';
@@ -4,6 +4,8 @@ import { MemoryRouter } from 'react-router-dom';
import ChangelogPage from './ChangelogPage';
import { CHANGELOG_ENTRIES } from './changelogEntries';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
describe('ChangelogPage', () => {
it('lists dated shipped notes and no founder', () => {
render(
@@ -2,6 +2,7 @@ import React from 'react';
import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { CHANGELOG_ENTRIES } from './changelogEntries';
import { publicLine, publicText } from '../../components/PublicSite/publicColors';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
@@ -17,13 +18,14 @@ const List = styled.ol`
`;
const Item = styled.li`
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid ${publicLine};
padding-top: 1rem;
`;
const When = styled.time`
display: block;
color: rgba(255, 255, 255, 0.6);
color: ${publicText};
opacity: 0.65;
font-size: 0.9rem;
margin-bottom: 0.35rem;
`;
@@ -35,7 +37,7 @@ const Heading = styled.h2`
const Note = styled.p`
margin: 0;
color: rgba(255, 255, 255, 0.82);
color: ${publicText};
line-height: 1.5;
max-width: 40rem;
`;
@@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import LandingPage from './LandingPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
const mockGet = jest.fn();
const mockPost = jest.fn();
@@ -5,6 +5,7 @@ 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';
import { publicAccent, publicCard, publicLine, publicText } from '../../components/PublicSite/publicColors';
const Hero = styled.header`
max-width: 40rem;
@@ -27,14 +28,15 @@ const Lede = styled.p`
font-size: 1.25rem;
line-height: 1.45;
margin: 0 0 0.75rem;
color: rgba(255, 255, 255, 0.92);
color: ${publicText};
`;
const Memory = styled.p`
font-size: 1.05rem;
line-height: 1.5;
margin: 0 0 1.5rem;
color: rgba(255, 255, 255, 0.82);
color: ${publicText};
opacity: 0.85;
`;
const Privacy = styled.section`
@@ -45,12 +47,13 @@ const Privacy = styled.section`
const PrivacyList = styled.ul`
margin: 0.5rem 0 0.75rem;
padding-left: 1.2rem;
color: rgba(255, 255, 255, 0.82);
color: ${publicText};
opacity: 0.85;
line-height: 1.5;
`;
const TextLink = styled(Link)`
color: #c9d4ff;
color: ${publicAccent};
`;
const Features = styled.section`
@@ -60,10 +63,10 @@ const Features = styled.section`
`;
const Feature = styled.article`
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid ${publicLine};
border-radius: 0.75rem;
padding: 1.25rem 1.35rem;
background: rgba(255, 255, 255, 0.03);
background: ${publicCard};
`;
const FeatureTitle = styled.h2`
@@ -73,7 +76,8 @@ const FeatureTitle = styled.h2`
const FeatureBody = styled.p`
margin: 0;
color: rgba(255, 255, 255, 0.8);
color: ${publicText};
opacity: 0.85;
line-height: 1.5;
`;
@@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import PricingPage from './PricingPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
get: () => Promise.resolve({ data: { enable_account_registration: false } }),
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import styled from 'styled-components';
import GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
@@ -15,13 +16,13 @@ const Price = styled.p`
`;
const Body = styled.p`
color: rgba(255, 255, 255, 0.82);
color: ${publicText};
line-height: 1.5;
max-width: 36rem;
`;
const Back = styled(Link)`
color: #c9d4ff;
color: ${publicAccent};
`;
const PricingPage = (): JSX.Element => {
@@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import PrivacyPage from './PrivacyPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
describe('PrivacyPage', () => {
it('states storage, no ads, and that cancel does not delete documents', () => {
render(
@@ -2,6 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
@@ -20,12 +21,12 @@ const Heading = styled.h2`
const Body = styled.p`
margin: 0;
color: rgba(255, 255, 255, 0.82);
color: ${publicText};
line-height: 1.55;
`;
const TextLink = styled(Link)`
color: #c9d4ff;
color: ${publicAccent};
`;
const PrivacyPage = (): JSX.Element => {