Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/layouts/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function ProtectedLayout() {
useVaultTimeout()

return (
<div className="text-foreground bg-background flex h-screen">
<div className="text-foreground bg-background flex h-[calc(100vh-var(--banner-height))]">
{/* Desktop sidebar */}
<aside
className="bg-sidebar text-sidebar-foreground border-sidebar-border hidden flex-shrink-0 flex-col border-r md:flex"
Expand Down
6 changes: 2 additions & 4 deletions src/app/layouts/PublicLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { Outlet } from '@tanstack/react-router'
import { LanguageSwitcher } from '@/shared/ui/nav/LanguageSwitcher'
import { PublicHeader } from '@/shared/ui/nav/PublicHeader'

function PublicLayout() {
return (
<div className="bg-background text-foreground relative min-h-screen overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--color-primary)_0,_transparent_70%)] opacity-[0.08]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,_var(--color-primary)_0,_transparent_50%)] opacity-[0.03]" />
<div className="fixed top-4 right-4 z-50">
<LanguageSwitcher />
</div>
<PublicHeader />
<main className="container mx-auto flex min-h-screen items-center justify-center p-4">
<Outlet />
</main>
Expand Down
5 changes: 3 additions & 2 deletions src/app/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ function renderWithRouter(authOverrides: Partial<AuthContext> = {}, initialPath
}

describe('Router redirects', () => {
it('redirects / to /login when not authenticated', async () => {
it('does not redirect / to /login when not authenticated', async () => {
const { router } = renderWithRouter({}, '/')
await waitFor(() => {
expect(router.state.location.pathname).toBe('/login')
expect(router.state.location.pathname).not.toBe('/login')
expect(router.state.location.pathname).toBe('/')
})
})

Expand Down
2 changes: 2 additions & 0 deletions src/app/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ThemeProvider } from '@/shared/lib/theme-provider'
import { Toaster } from '@/shared/ui/sonner'
import { PageSkeleton } from '@/app/Pending'
import { RootErrorBoundary } from '@/app/ErrorBoundary'
import { PreAlphaBanner } from '@/shared/ui/PreAlphaBanner'
import type { AuthContext } from '@/shared/auth/auth-context'

interface RouterContext {
Expand All @@ -18,6 +19,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
function RootLayout() {
return (
<ThemeProvider defaultTheme="dark">
<PreAlphaBanner />
<Outlet />
<Toaster />
</ThemeProvider>
Expand Down
5 changes: 4 additions & 1 deletion src/app/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { lazy } from 'react'

const LandingPage = lazy(() => import('@/features/landing/ui/LandingPage'))

const Route = createFileRoute('/')({
beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated) {
throw redirect({ to: '/dashboard' })
}
throw redirect({ to: '/login' })
},
component: LandingPage,
})

export { Route }
65 changes: 34 additions & 31 deletions src/app/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -51,43 +51,46 @@
}

:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(0.995 0.005 285);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(0.995 0.005 285);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.4 0.17 285);
color-scheme: light;
--banner-height: 32px;
--background: oklch(0.98 0.004 285);
--foreground: oklch(0.18 0.015 285);
--card: oklch(1 0 0);
--card-foreground: oklch(0.18 0.015 285);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.18 0.015 285);
--primary: oklch(0.42 0.14 285);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0.006 285);
--secondary-foreground: oklch(0.3 0.12 285);
--muted: oklch(0.96 0.008 285);
--muted-foreground: oklch(0.5 0.08 285);
--accent: oklch(0.96 0.012 300);
--accent-foreground: oklch(0.3 0.12 285);
--secondary: oklch(0.94 0.012 285);
--secondary-foreground: oklch(0.28 0.1 285);
--muted: oklch(0.93 0.008 285);
--muted-foreground: oklch(0.46 0.05 285);
--accent: oklch(0.93 0.014 300);
--accent-foreground: oklch(0.28 0.1 285);
--destructive: oklch(0.577 0.245 20);
--success: oklch(0.55 0.17 155);
--warning: oklch(0.82 0.16 80);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.55 0.15 285);
--chart-1: oklch(0.55 0.17 285);
--chart-2: oklch(0.55 0.14 300);
--chart-3: oklch(0.6 0.14 190);
--chart-4: oklch(0.6 0.14 150);
--chart-5: oklch(0.65 0.15 70);
--success: oklch(0.52 0.17 155);
--warning: oklch(0.72 0.16 80);
--border: oklch(0.88 0.012 285);
--input: oklch(0.84 0.016 285);
--ring: oklch(0.5 0.12 285);
--chart-1: oklch(0.5 0.15 285);
--chart-2: oklch(0.52 0.13 300);
--chart-3: oklch(0.55 0.14 190);
--chart-4: oklch(0.55 0.14 150);
--chart-5: oklch(0.6 0.15 70);
--radius: 0.625rem;
--sidebar: oklch(0.995 0.006 285);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.4 0.17 285);
--sidebar: oklch(0.955 0.01 285);
--sidebar-foreground: oklch(0.18 0.015 285);
--sidebar-primary: oklch(0.42 0.14 285);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.96 0.012 300);
--sidebar-accent-foreground: oklch(0.3 0.12 285);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.55 0.15 285);
--sidebar-accent: oklch(0.93 0.014 300);
--sidebar-accent-foreground: oklch(0.28 0.1 285);
--sidebar-border: oklch(0.88 0.012 285);
--sidebar-ring: oklch(0.5 0.12 285);
}

.dark {
color-scheme: dark;
--background: oklch(0.18 0.01 285);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0.02 285);
Expand Down Expand Up @@ -132,7 +135,7 @@
}
html {
@apply font-sans;
color-scheme: dark;
scrollbar-gutter: stable;
}
button,
a,
Expand Down
22 changes: 22 additions & 0 deletions src/features/landing/ui/CtaButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'

import { buttonVariants } from '@/shared/ui/button'
import { cn } from '@/shared/lib/utils'

function CtaButtons() {
const { t } = useTranslation('landing')

return (
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link to="/register" className={cn(buttonVariants({ size: 'lg' }), 'h-12 px-8 text-base')}>
{t('hero.cta')}
</Link>
<Link to="/login" className={cn(buttonVariants({ variant: 'outline', size: 'lg' }), 'h-12 px-8 text-base')}>
{t('hero.login')}
</Link>
</div>
)
}

export { CtaButtons }
53 changes: 53 additions & 0 deletions src/features/landing/ui/FeaturesGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ShieldCheck, KeyRound, Fingerprint, Lock } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { LucideIcon } from 'lucide-react'

interface FeatureItem {
icon: LucideIcon
titleKey: string
descriptionKey: string
}

const FEATURES: FeatureItem[] = [
{ icon: ShieldCheck, titleKey: 'features.zeroKnowledge.title', descriptionKey: 'features.zeroKnowledge.description' },
{ icon: KeyRound, titleKey: 'features.splitKey.title', descriptionKey: 'features.splitKey.description' },
{ icon: Fingerprint, titleKey: 'features.recovery.title', descriptionKey: 'features.recovery.description' },
{ icon: Lock, titleKey: 'features.openDesign.title', descriptionKey: 'features.openDesign.description' },
]

function FeaturesGrid() {
const { t } = useTranslation('landing')

return (
<section className="px-6 py-24 sm:py-32">
<div className="mx-auto max-w-6xl">
<h2 className="text-foreground mb-16 text-center text-3xl font-bold tracking-tight sm:text-4xl">
{t('features.heading')}
</h2>

<div className="grid gap-8 sm:grid-cols-2 lg:gap-12">
{FEATURES.map((feature) => (
<FeatureCard key={feature.titleKey} feature={feature} />
))}
</div>
</div>
</section>
)
}

function FeatureCard({ feature }: { feature: FeatureItem }) {
const Icon = feature.icon
const { t } = useTranslation('landing')

return (
<div className="bg-card/50 border-border/50 group hover:border-primary/20 hover:shadow-primary/5 rounded-xl border p-8 backdrop-blur-sm transition-all duration-300 hover:shadow-lg">
<div className="bg-primary/10 text-primary mb-5 inline-flex rounded-lg p-3">
<Icon className="size-6" />
</div>
<h3 className="text-foreground mb-3 text-lg font-semibold">{t(feature.titleKey)}</h3>
<p className="text-muted-foreground leading-relaxed">{t(feature.descriptionKey)}</p>
</div>
)
}

export { FeaturesGrid }
31 changes: 31 additions & 0 deletions src/features/landing/ui/HeroSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'

import { CipherNoteIcon } from '@/shared/ui/brand/CipherNoteIcon'
import { CtaButtons } from '@/features/landing/ui/CtaButtons'

function HeroSection() {
const { t } = useTranslation('landing')

return (
<section className="relative flex min-h-screen flex-col items-center justify-center px-6 py-32 text-center">
<div className="animate-fade-in-up relative z-10 mx-auto max-w-4xl">
<div className="mb-8 flex items-center justify-center gap-3">
<CipherNoteIcon className="size-14" />
<span className="text-foreground text-3xl font-bold tracking-tight">Cipher Note</span>
</div>

<h1 className="text-foreground mb-6 text-4xl leading-[1.15] font-bold tracking-tight sm:text-5xl md:text-6xl">
{t('hero.title')}
</h1>

<p className="text-muted-foreground mx-auto mb-12 max-w-2xl text-lg leading-relaxed sm:text-xl">
{t('hero.subtitle')}
</p>

<CtaButtons />
</div>
</section>
)
}

export { HeroSection }
66 changes: 66 additions & 0 deletions src/features/landing/ui/HowItWorks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { UserPlus, PenLine, ShieldCheck } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { LucideIcon } from 'lucide-react'

interface Step {
icon: LucideIcon
titleKey: string
descriptionKey: string
}

const STEPS: Step[] = [
{ icon: UserPlus, titleKey: 'howItWorks.step1.title', descriptionKey: 'howItWorks.step1.description' },
{ icon: PenLine, titleKey: 'howItWorks.step2.title', descriptionKey: 'howItWorks.step2.description' },
{ icon: ShieldCheck, titleKey: 'howItWorks.step3.title', descriptionKey: 'howItWorks.step3.description' },
]

function HowItWorks() {
const { t } = useTranslation('landing')

return (
<section className="px-6 py-24 sm:py-32">
<div className="mx-auto max-w-5xl">
<h2 className="text-foreground mb-16 text-center text-3xl font-bold tracking-tight sm:text-4xl">
{t('howItWorks.heading')}
</h2>

<div className="grid gap-12 md:grid-cols-3 md:gap-8">
{STEPS.map((step, index) => (
<StepCard key={step.titleKey} step={step} index={index} last={index === STEPS.length - 1} t={t} />
))}
</div>
</div>
</section>
)
}

interface StepCardProps {
step: Step
index: number
last: boolean
t: (key: string) => string
}

function StepCard({ step, index, last, t }: StepCardProps) {
const Icon = step.icon

return (
<div className="flex flex-col items-center text-center">
<span className="text-muted-foreground/30 mb-2 font-mono text-5xl leading-none font-bold select-none">
{index + 1}
</span>
<div className="relative mb-8 flex w-full items-center justify-center">
{!last && (
<div className="from-border via-primary/40 to-border absolute left-1/2 hidden h-px w-full bg-gradient-to-r md:block" />
)}
<div className="bg-primary text-primary-foreground shadow-primary/20 relative z-10 flex size-14 items-center justify-center rounded-full shadow-lg">
<Icon className="size-6" />
</div>
</div>
<h3 className="text-foreground mb-3 text-lg font-semibold">{t(step.titleKey)}</h3>
<p className="text-muted-foreground leading-relaxed">{t(step.descriptionKey)}</p>
</div>
)
}

export { HowItWorks }
33 changes: 33 additions & 0 deletions src/features/landing/ui/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next'

import { HeroSection } from '@/features/landing/ui/HeroSection'
import { FeaturesGrid } from '@/features/landing/ui/FeaturesGrid'
import { HowItWorks } from '@/features/landing/ui/HowItWorks'
import { SecurityBanner } from '@/features/landing/ui/SecurityBanner'
import { PublicHeader } from '@/shared/ui/nav/PublicHeader'

function LandingPage() {
const { t } = useTranslation('landing')

return (
<div className="bg-background text-foreground relative min-h-screen">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--color-primary)_0,_transparent_70%)] opacity-[0.06]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_right,_var(--color-primary)_0,_transparent_50%)] opacity-[0.03]" />

<PublicHeader />

<main className="relative">
<HeroSection />
<FeaturesGrid />
<HowItWorks />
<SecurityBanner />
</main>

<footer className="border-border/50 relative border-t px-6 py-8 text-center">
<p className="text-muted-foreground text-sm">{t('footer.tagline')}</p>
</footer>
</div>
)
}

export default LandingPage
21 changes: 21 additions & 0 deletions src/features/landing/ui/SecurityBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useTranslation } from 'react-i18next'

import { CtaButtons } from '@/features/landing/ui/CtaButtons'

function SecurityBanner() {
const { t } = useTranslation('landing')

return (
<section className="relative overflow-hidden px-6 py-24 sm:py-32">
<div className="bg-card/60 border-border/50 mx-auto max-w-4xl rounded-2xl border p-12 text-center backdrop-blur-sm sm:p-16">
<p className="text-muted-foreground mx-auto mb-10 max-w-xl text-lg leading-relaxed font-medium sm:text-xl">
{t('security.statement')}
</p>

<CtaButtons />
</div>
</section>
)
}

export { SecurityBanner }
Loading
Loading