Move legal pages to React Router

This commit is contained in:
rosetta
2026-07-30 16:34:18 -07:00
parent 4daf0ffba9
commit 856fb60579
17 changed files with 857 additions and 805 deletions

View File

@@ -1,4 +1,5 @@
import { Anchor, Text } from '@mantine/core';
import { Link } from 'react-router-dom';
import classes from './Footer.module.css';
const LEGAL_NAME = 'ROSETTA CLOUD SERVICES LIMITED';
@@ -15,10 +16,10 @@ export function Footer() {
<Anchor href="/support/" size="sm" c="dimmed">
Support
</Anchor>
<Anchor href="/privacy/" size="sm" c="dimmed">
<Anchor component={Link} to="/privacy" size="sm" c="dimmed">
Privacy Policy
</Anchor>
<Anchor href="/terms/" size="sm" c="dimmed">
<Anchor component={Link} to="/terms" size="sm" c="dimmed">
Terms of Use
</Anchor>
</nav>

View File

@@ -0,0 +1,163 @@
.page {
min-height: 100vh;
background:
radial-gradient(circle at 50% 0%, light-dark(rgba(34, 139, 230, 0.12), rgba(34, 139, 230, 0.16)), transparent 34rem),
light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
.shell {
padding-top: var(--mantine-spacing-lg);
padding-bottom: calc(var(--mantine-spacing-xl) * 2);
}
.header {
min-height: 58px;
padding-bottom: var(--mantine-spacing-md);
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.brand {
display: inline-flex;
align-items: center;
gap: var(--mantine-spacing-xs);
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
text-decoration: none;
}
.brand img {
border-radius: var(--mantine-radius-sm);
}
.main {
width: min(100%, 820px);
margin: 0 auto;
padding-top: 76px;
}
.intro {
padding: 0 var(--mantine-spacing-md) calc(var(--mantine-spacing-xl) * 1.5);
}
.title {
margin-top: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-family: Outfit, var(--mantine-font-family);
font-size: clamp(40px, 7vw, 62px);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 1.08;
}
.document {
padding: 44px 48px 52px;
background: light-dark(rgba(255, 255, 255, 0.9), rgba(20, 21, 23, 0.9));
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
backdrop-filter: blur(12px);
}
.lead {
margin: 0;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
font-size: var(--mantine-font-size-lg);
line-height: 1.65;
}
.text,
.list {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-md);
line-height: 1.7;
}
.text {
margin: 0 0 var(--mantine-spacing-md);
}
.text:last-child,
.address:last-child {
margin-bottom: 0;
}
.list {
margin-bottom: var(--mantine-spacing-md);
padding-left: var(--mantine-spacing-xs);
}
.list li::marker {
color: var(--mantine-color-blue-5);
}
.section {
margin-top: 42px;
padding-top: 36px;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.sectionTitle {
margin: 0 0 var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
font-family: Outfit, var(--mantine-font-family);
font-size: clamp(22px, 3vw, 28px);
font-weight: 500;
line-height: 1.25;
}
.document a {
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3));
text-underline-offset: 3px;
overflow-wrap: anywhere;
}
.address {
margin: var(--mantine-spacing-md) 0;
border-left: 3px solid var(--mantine-color-blue-5);
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
}
.address p {
margin: 0;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
line-height: 1.65;
white-space: pre-line;
}
.footer {
margin-top: 72px;
padding-top: var(--mantine-spacing-lg);
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
@media (max-width: 48em) {
.main {
padding-top: 52px;
}
.document {
padding: 32px 24px 40px;
}
}
@media (max-width: 36em) {
.shell {
padding-inline: var(--mantine-spacing-md);
}
.header {
align-items: flex-start;
flex-direction: column;
}
.intro {
padding-inline: 0;
}
.document {
margin-inline: calc(var(--mantine-spacing-xs) * -1);
padding-inline: 20px;
}
.footer {
align-items: flex-start;
flex-direction: column;
}
}

View File

@@ -0,0 +1,165 @@
import type { ReactNode } from 'react';
import {
Anchor,
Box,
Container,
Group,
List,
Paper,
Text,
Title,
} from '@mantine/core';
import { Link } from 'react-router-dom';
import { RosettaLogo } from '../RosettaLogo/RosettaLogo';
import classes from './LegalPage.module.css';
type LegalRoute = 'privacy' | 'terms';
interface LegalPageProps {
active: LegalRoute;
title: string;
updated: string;
children: ReactNode;
}
interface LegalSectionProps {
title: string;
children: ReactNode;
}
export function LegalPage({ active, title, updated, children }: LegalPageProps) {
const currentYear = new Date().getFullYear();
return (
<Box className={classes.page}>
<Container size="lg" className={classes.shell}>
<Group component="header" justify="space-between" className={classes.header}>
<Anchor
component={Link}
to="/"
className={classes.brand}
aria-label="Rosetta home"
>
<RosettaLogo size={34} />
<Text component="span" fw={700} fz="lg">
Rosetta
</Text>
</Anchor>
<Group component="nav" gap="lg" aria-label="Legal pages">
<Anchor href="/support/" c="dimmed" size="sm">
Support
</Anchor>
<Anchor
component={Link}
to="/privacy"
c={active === 'privacy' ? 'blue' : 'dimmed'}
fw={active === 'privacy' ? 600 : 400}
size="sm"
aria-current={active === 'privacy' ? 'page' : undefined}
>
Privacy
</Anchor>
<Anchor
component={Link}
to="/terms"
c={active === 'terms' ? 'blue' : 'dimmed'}
fw={active === 'terms' ? 600 : 400}
size="sm"
aria-current={active === 'terms' ? 'page' : undefined}
>
Terms
</Anchor>
</Group>
</Group>
<Box component="main" className={classes.main}>
<Box className={classes.intro}>
<Text tt="uppercase" fw={700} fz="xs" c="blue" lts="0.08em">
Rosetta legal
</Text>
<Title order={1} className={classes.title}>
{title}
</Title>
<Text c="dimmed" mt="md">
Last updated: {updated}
</Text>
</Box>
<Paper
component="article"
radius="lg"
withBorder
shadow="sm"
className={classes.document}
>
{children}
</Paper>
</Box>
<Group component="footer" justify="space-between" className={classes.footer}>
<Text c="dimmed" fz="xs">
&copy; {currentYear} ROSETTA CLOUD SERVICES LIMITED
</Text>
<Group component="nav" gap="md" aria-label="Footer links">
<Anchor component={Link} to="/" c="dimmed" size="xs">
Rosetta home
</Anchor>
<Anchor href="/support/" c="dimmed" size="xs">
Support
</Anchor>
<Anchor
component={Link}
to={active === 'privacy' ? '/terms' : '/privacy'}
c="dimmed"
size="xs"
>
{active === 'privacy' ? 'Terms of Use' : 'Privacy Policy'}
</Anchor>
</Group>
</Group>
</Container>
</Box>
);
}
export function LegalSection({ title, children }: LegalSectionProps) {
return (
<Box component="section" className={classes.section}>
<Title order={2} className={classes.sectionTitle}>
{title}
</Title>
{children}
</Box>
);
}
export function LegalText({
children,
lead = false,
}: {
children: ReactNode;
lead?: boolean;
}) {
return (
<Text component="p" className={lead ? classes.lead : classes.text}>
{children}
</Text>
);
}
export function LegalList({ children }: { children: ReactNode }) {
return (
<List spacing="xs" className={classes.list}>
{children}
</List>
);
}
export function LegalAddress({ children }: { children: ReactNode }) {
return (
<Paper radius="md" p="md" className={classes.address}>
<Text component="p">{children}</Text>
</Paper>
);
}

View File

@@ -5,28 +5,31 @@ interface SEOProps {
description?: string;
ogImage?: string;
keywords?: string;
canonical?: string;
}
export function SEO({
title = 'Rosetta - Secure Messaging for Everyone',
description = 'End-to-end encrypted messaging app with military-grade security. Download for Windows, Linux, and Android.',
ogImage = '/og-image.png',
keywords = 'secure messaging, encrypted chat, private messenger'
ogImage = '/og.png',
keywords = 'secure messaging, encrypted chat, private messenger',
canonical = 'https://rosetta.im/'
}: SEOProps) {
useEffect(() => {
// Update title
document.title = title;
// Update meta tags
updateMetaTag('name', 'description', description);
updateMetaTag('name', 'keywords', keywords);
updateMetaTag('property', 'og:title', title);
updateMetaTag('property', 'og:description', description);
updateMetaTag('property', 'og:image', ogImage);
updateMetaTag('property', 'og:url', canonical);
updateMetaTag('name', 'twitter:title', title);
updateMetaTag('name', 'twitter:description', description);
updateMetaTag('name', 'twitter:image', ogImage);
}, [title, description, ogImage, keywords]);
updateMetaTag('name', 'twitter:url', canonical);
updateCanonical(canonical);
}, [title, description, ogImage, keywords, canonical]);
return null;
}
@@ -42,3 +45,15 @@ function updateMetaTag(attr: string, name: string, content: string) {
element.setAttribute('content', content);
}
function updateCanonical(href: string) {
let element = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!element) {
element = document.createElement('link');
element.rel = 'canonical';
document.head.appendChild(element);
}
element.href = href;
}

View File

@@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}, [pathname]);
return null;
}