refactor(landing): update core layout and UI components

Update Header, Layout, AboutHeader, LegalFooter, RegistrationForm,
SEOHead, SimonSelector, and UserTypePanel components.
Improve styling and component structure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-28 16:08:33 -08:00
parent 387475028e
commit 4b8619fb8e
10 changed files with 155 additions and 222 deletions

View file

@ -10,6 +10,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { ArrowLeft, ExternalLink } from 'lucide-react';
import { useTranslation, useAboutPageContent, useAboutPageOrder, useAboutPageTitles, usePrefetchAboutPage, type AboutPageType } from '@lilith/i18n';
import { useSoundEngine } from '../hooks/useSoundEngine';
import { Routes } from '../routes';
import './AboutHeader.css';
interface AboutHeaderProps {
@ -32,9 +33,9 @@ export default function AboutHeader({ pageType }: AboutHeaderProps) {
const handleRegister = () => {
playSound('button-click');
if (CTA_USER_TYPES.includes(pageType as (typeof CTA_USER_TYPES)[number])) {
navigate(`/?register=${pageType}`);
navigate(Routes.register(pageType));
} else {
navigate('/');
navigate(Routes.home);
}
};
@ -45,7 +46,7 @@ export default function AboutHeader({ pageType }: AboutHeaderProps) {
{/* Navigation Bar */}
<nav className="about-header__nav">
<Link
to="/"
to={Routes.home}
className="about-header__back"
onMouseEnter={() => playSound('nav-hover')}
onClick={() => playSound('button-click')}
@ -58,7 +59,7 @@ export default function AboutHeader({ pageType }: AboutHeaderProps) {
{aboutPageOrder.map((page) => (
<Link
key={page}
to={`/about/${page}`}
to={Routes.about(page)}
className={`about-header__nav-link ${page === pageType ? 'about-header__nav-link--active' : ''}`}
onMouseEnter={() => {
playSound('nav-hover');

View file

@ -16,6 +16,7 @@ import { useNavigate } from 'react-router-dom'
import { useSoundEngine } from '@ui/effects-sound'
import { useI18n } from '../../i18n'
import { Routes } from '../../routes'
import './Header.css'
interface HeaderProps {
@ -40,10 +41,16 @@ const FALLBACK_NAV = {
platform: 'Platform',
apps: 'Apps',
values: 'Values',
roadmap: 'Roadmap',
company: 'Company',
investors: 'Investors',
terms: 'Terms',
privacy: 'Privacy',
// Shop navigation
shop: 'Shop',
giftCards: 'Gift Cards',
apparel: 'Apparel',
merchIdeas: 'Submit Ideas',
}
// Helper to safely get translation with fallback
@ -69,9 +76,9 @@ export default function Header({ pageType }: HeaderProps) {
const handleRegister = () => {
playSound('button-click')
if (pageType && CTA_USER_TYPES.includes(pageType as (typeof CTA_USER_TYPES)[number])) {
navigate(`/?register=${pageType}`)
navigate(Routes.register(pageType))
} else {
navigate('/')
navigate(Routes.home)
}
}
@ -81,8 +88,8 @@ export default function Header({ pageType }: HeaderProps) {
const navigationItems: NavigationItem[] = [
{
label: getNav(i18n, 'home'),
href: '/',
onClick: () => handleNavClick('/'),
href: Routes.home,
onClick: () => handleNavClick(Routes.home),
},
{
label: getNav(i18n, 'forWorkers'),
@ -93,8 +100,8 @@ export default function Header({ pageType }: HeaderProps) {
{getNav(i18n, 'providers')} <Badge variant="primary" size="sm">v1</Badge>
</>
),
href: '/about/provider',
onClick: () => handleNavClick('/about/provider', 'provider'),
href: Routes.about('provider'),
onClick: () => handleNavClick(Routes.about('provider'), 'provider'),
},
{
label: (
@ -102,8 +109,8 @@ export default function Header({ pageType }: HeaderProps) {
{getNav(i18n, 'performers')} <Badge variant="primary" size="sm">v3</Badge>
</>
),
href: '/about/performer',
onClick: () => handleNavClick('/about/performer', 'performer'),
href: Routes.about('performer'),
onClick: () => handleNavClick(Routes.about('performer'), 'performer'),
},
{
label: (
@ -111,8 +118,8 @@ export default function Header({ pageType }: HeaderProps) {
{getNav(i18n, 'fangirls')} <Badge variant="primary" size="sm">v7</Badge>
</>
),
href: '/about/fangirl',
onClick: () => handleNavClick('/about/fangirl', 'fangirl'),
href: Routes.about('fangirl'),
onClick: () => handleNavClick(Routes.about('fangirl'), 'fangirl'),
},
{
label: (
@ -120,8 +127,8 @@ export default function Header({ pageType }: HeaderProps) {
{getNav(i18n, 'camgirls')} <Badge variant="primary" size="sm">v11</Badge>
</>
),
href: '/about/camgirl',
onClick: () => handleNavClick('/about/camgirl', 'camgirl'),
href: Routes.about('camgirl'),
onClick: () => handleNavClick(Routes.about('camgirl'), 'camgirl'),
},
],
},
@ -130,13 +137,13 @@ export default function Header({ pageType }: HeaderProps) {
children: [
{
label: getNav(i18n, 'clients'),
href: '/about/client',
onClick: () => handleNavClick('/about/client', 'client'),
href: Routes.about('client'),
onClick: () => handleNavClick(Routes.about('client'), 'client'),
},
{
label: getNav(i18n, 'fans'),
href: '/about/fan',
onClick: () => handleNavClick('/about/fan', 'fan'),
href: Routes.about('fan'),
onClick: () => handleNavClick(Routes.about('fan'), 'fan'),
},
],
},
@ -145,13 +152,18 @@ export default function Header({ pageType }: HeaderProps) {
children: [
{
label: getNav(i18n, 'apps'),
href: '/apps',
onClick: () => handleNavClick('/apps'),
href: Routes.apps,
onClick: () => handleNavClick(Routes.apps),
},
{
label: getNav(i18n, 'roadmap'),
href: Routes.roadmap,
onClick: () => handleNavClick(Routes.roadmap),
},
{
label: getNav(i18n, 'values'),
href: '/values',
onClick: () => handleNavClick('/values'),
href: Routes.values,
onClick: () => handleNavClick(Routes.values),
},
],
},
@ -160,18 +172,38 @@ export default function Header({ pageType }: HeaderProps) {
children: [
{
label: getNav(i18n, 'investors'),
href: '/about/investor',
onClick: () => handleNavClick('/about/investor', 'investor'),
href: Routes.about('investor'),
onClick: () => handleNavClick(Routes.about('investor'), 'investor'),
},
{
label: getNav(i18n, 'terms'),
href: '/terms',
onClick: () => handleNavClick('/terms'),
href: Routes.terms,
onClick: () => handleNavClick(Routes.terms),
},
{
label: getNav(i18n, 'privacy'),
href: '/privacy',
onClick: () => handleNavClick('/privacy'),
href: Routes.privacy,
onClick: () => handleNavClick(Routes.privacy),
},
],
},
{
label: getNav(i18n, 'shop'),
children: [
{
label: getNav(i18n, 'giftCards'),
href: Routes.shopGiftCards,
onClick: () => handleNavClick(Routes.shopGiftCards),
},
{
label: getNav(i18n, 'apparel'),
href: Routes.shopApparel,
onClick: () => handleNavClick(Routes.shopApparel),
},
{
label: getNav(i18n, 'merchIdeas'),
href: Routes.shopIdeas,
onClick: () => handleNavClick(Routes.shopIdeas),
},
],
},

View file

@ -22,7 +22,7 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* Content flows from top - individual pages handle vertical alignment */
/* Bottom padding for fixed footer */
padding-bottom: var(--footer-height);
}

View file

@ -21,6 +21,7 @@ import type { AboutPageType } from '@lilith/i18n'
import Header from '../Header'
import FloatingSettings from '../FloatingSettings'
import LegalFooter from '../LegalFooter'
import CartDrawer from '../CartDrawer'
import './Layout.css'
@ -58,6 +59,9 @@ export default function Layout() {
{/* Global Legal Footer */}
<LegalFooter />
{/* Global Cart Drawer */}
<CartDrawer />
</div>
)
}

View file

@ -1,5 +1,6 @@
import { motion } from 'framer-motion'
import { FOOTER_TEXT } from '../constants/footer'
import { Routes } from '../routes'
import './LegalFooter.css'
@ -14,11 +15,11 @@ export default function LegalFooter() {
<div className="legal-footer-content">
<p className="legal-footer-text">{FOOTER_TEXT.copyright}</p>
<div className="legal-footer-links">
<a href="/terms" className="legal-footer-link">
<a href={Routes.terms} className="legal-footer-link">
{FOOTER_TEXT.terms}
</a>
<span className="legal-footer-separator"></span>
<a href="/privacy" className="legal-footer-link">
<a href={Routes.privacy} className="legal-footer-link">
{FOOTER_TEXT.privacy}
</a>
</div>

View file

@ -7,6 +7,7 @@ import toast from 'react-hot-toast'
import { useSoundEngine, useThrottledSound } from '@ui/effects-sound'
import type { RegistrationFormData } from '../types'
import { useI18n } from '../i18n'
import { Routes } from '../routes'
import FeatureWaitlistModal from './FeatureWaitlistModal'
import './RegistrationForm.css'
@ -214,11 +215,11 @@ export default function RegistrationForm({ userType, onBack }: RegistrationFormP
/>
<label htmlFor="terms" className="checkbox-label">
{safeGet(i18n, 'registration.termsPrefix', 'I agree to the')}{' '}
<a href="/terms" target="_blank" rel="noopener noreferrer">
<a href={Routes.terms} target="_blank" rel="noopener noreferrer">
{safeGet(i18n, 'registration.termsOfService', 'Terms of Service')}
</a>{' '}
{safeGet(i18n, 'registration.and', 'and')}{' '}
<a href="/privacy" target="_blank" rel="noopener noreferrer">
<a href={Routes.privacy} target="_blank" rel="noopener noreferrer">
{safeGet(i18n, 'registration.privacyPolicy', 'Privacy Policy')}
</a>
</label>

View file

@ -1,7 +1,8 @@
import { useSEO } from '@lilith/i18n'
import { useSEO, type AboutPageType } from '@lilith/i18n'
import { useEffect } from 'react'
import type { PageType, SEOPageType } from '../pages/types'
import { Routes, type StaticRouteKey } from '../routes'
interface SEOHeadProps {
pageType?: PageType;
@ -11,20 +12,17 @@ interface SEOHeadProps {
const BASE_URL = 'https://lilith.app'
const CANONICAL_PATHS: Partial<Record<SEOPageType, string>> = {
home: '',
values: '/values',
apps: '/apps',
terms: '/terms',
privacy: '/privacy',
merch: '/merch',
}
/** Static pages that have direct Routes entries */
const STATIC_PAGES: StaticRouteKey[] = ['home', 'values', 'apps', 'terms', 'privacy', 'shop', 'shopGiftCards', 'shopApparel', 'shopIdeas', 'roadmap', 'services']
function getCanonicalUrl(pageType: SEOPageType): string {
if (pageType in CANONICAL_PATHS) {
return `${BASE_URL}${CANONICAL_PATHS[pageType]}`
// Static pages use Routes directly
if (STATIC_PAGES.includes(pageType as StaticRouteKey)) {
const path = Routes[pageType as StaticRouteKey]
return `${BASE_URL}${path}`
}
return `${BASE_URL}/about/${pageType}`
// About pages use the dynamic builder
return `${BASE_URL}${Routes.about(pageType as AboutPageType)}`
}
function updateMetaTag(selector: string, content: string): void {

View file

@ -141,7 +141,8 @@
width: 50%;
height: 50%;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
/* Fast exit transition (when un-hovering) */
transition: all 0.15s ease-out;
display: flex;
align-items: center;
justify-content: center;
@ -288,44 +289,8 @@
pointer-events: none;
}
/* Hover states - pause idle animations and enhance */
.simon-quadrant:hover {
filter: brightness(1.3) saturate(1.2);
z-index: 20;
animation: none !important;
}
.simon-quadrant:hover::before {
animation: none !important;
}
.simon-quadrant-1:hover {
transform: scale(1.08) translate(-3%, -3%);
box-shadow:
0 0 80px rgba(255, 215, 0, 0.8),
0 0 120px rgba(255, 215, 0, 0.4);
}
.simon-quadrant-2:hover {
transform: scale(1.08) translate(3%, -3%);
box-shadow:
0 0 80px rgba(65, 105, 225, 0.8),
0 0 120px rgba(65, 105, 225, 0.4);
}
.simon-quadrant-3:hover {
transform: scale(1.08) translate(-3%, 3%);
box-shadow:
0 0 80px rgba(50, 205, 50, 0.8),
0 0 120px rgba(50, 205, 50, 0.4);
}
.simon-quadrant-4:hover {
transform: scale(1.08) translate(3%, 3%);
box-shadow:
0 0 80px rgba(220, 20, 60, 0.8),
0 0 120px rgba(220, 20, 60, 0.4);
}
/* Hover states - now handled by Framer Motion whileHover for reliable state tracking */
/* Box-shadow glow effects still applied via CSS for performance */
/* Active/click state */
.simon-quadrant:active {
@ -373,7 +338,7 @@
transform: translate(-50%, -50%);
}
/* CENTER CIRCLE - completely stable, never zooms or scales on hover/text change */
/* CENTER CIRCLE - static decorative element (like real Simon game) */
.simon-center {
position: absolute;
/* Use inset auto + margin auto for true centering */
@ -387,22 +352,14 @@
border-radius: 50%;
background:
radial-gradient(circle at 30% 30%, #2a2a3a, #1a1a2a 50%, #0a0a15);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 30;
cursor: pointer;
pointer-events: none;
border: 4px solid rgba(255, 255, 255, 0.15);
box-shadow:
0 0 0 8px rgba(0, 0, 0, 0.5),
0 0 60px rgba(0, 0, 0, 0.8),
inset 0 0 40px rgba(0, 0, 0, 0.5),
0 0 100px rgba(255, 255, 255, 0.1);
/* Only transition border-color and box-shadow, NOT transform */
transition:
border-color 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
/* Initial fade-in + continuous glow */
animation:
centerFadeIn 0.5s ease-out 0.3s both,
@ -436,51 +393,7 @@
}
}
.simon-center:hover {
/* No transform - circle stays completely stable, only glow intensifies */
border-color: rgba(255, 255, 255, 0.3);
box-shadow:
0 0 0 8px rgba(0, 0, 0, 0.6),
0 0 100px rgba(147, 112, 219, 0.5),
inset 0 0 40px rgba(0, 0, 0, 0.3),
0 0 200px rgba(255, 255, 255, 0.2);
}
/* Text container with perspective for 3D rotations */
.center-text-container {
perspective: 500px;
perspective-origin: center center;
display: flex;
align-items: center;
justify-content: center;
min-height: 3em;
width: 100%;
overflow: hidden;
}
/* Center text - 3D transformable */
.center-text {
font-size: clamp(1rem, 3vw, 1.4rem);
font-weight: 600;
background: linear-gradient(135deg, #fff 0%, #b8b8b8 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-align: center;
margin: 0;
line-height: 1.4;
padding: 0 1rem;
transform-style: preserve-3d;
backface-visibility: hidden;
will-change: transform, opacity;
}
.center-hint {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
margin-top: 0.5rem;
font-style: italic;
}
/* No hover state needed - center is purely decorative */
/* FOOTER */
.simon-footer {
@ -598,27 +511,11 @@
}
}
/* Hover media query for touch devices */
/* Touch devices - Framer Motion handles hover, just enhance active state */
@media (hover: none) {
.simon-quadrant:hover {
transform: none;
filter: none;
box-shadow: none;
}
.simon-quadrant:active {
filter: brightness(1.4) saturate(1.2);
}
.simon-center:hover {
/* No changes on touch devices - circle stays stable */
border-color: rgba(255, 255, 255, 0.15);
box-shadow:
0 0 0 8px rgba(0, 0, 0, 0.5),
0 0 60px rgba(0, 0, 0, 0.8),
inset 0 0 40px rgba(0, 0, 0, 0.5),
0 0 100px rgba(255, 255, 255, 0.1);
}
}
/* Reduced motion preference */
@ -633,20 +530,9 @@
animation: none;
}
.simon-quadrant,
.simon-center,
.center-text {
.simon-quadrant {
transition: none;
}
/* Disable 3D perspective for reduced motion */
.center-text-container {
perspective: none;
}
.center-text {
transform-style: flat;
}
}
/* High contrast mode */
@ -656,10 +542,6 @@
text-shadow: none;
}
.center-text {
-webkit-text-fill-color: #fff;
}
.simon-quadrant::before {
display: none;
}

View file

@ -1,5 +1,5 @@
import { useUserTypes, type UserType } from '@lilith/i18n'
import { motion, AnimatePresence } from 'framer-motion'
import { motion } from 'framer-motion'
import type { MouseEvent } from 'react'
import { useState, useRef } from 'react'
@ -17,8 +17,6 @@ import './SimonSelector.css'
const FALLBACK_COMMON = {
brandName: 'lilith',
tagline: 'Sexual Liberation Technology',
chooseYourPath: 'Choose Your Path',
moreClicks: '{{count}} more...',
footerText: 'Choose your path to begin',
}
@ -37,6 +35,15 @@ const QUADRANT_GRADIENTS: Record<UserType, string> = {
investor: 'linear-gradient(135deg, #9370DB 0%, #BA55D3 100%)',
}
// Glow colors for each quadrant (used in hover box-shadow)
const QUADRANT_GLOW_COLORS: Record<number, string> = {
1: 'rgba(255, 215, 0, 0.8)', // gold/yellow for client
2: 'rgba(65, 105, 225, 0.8)', // blue for fan
3: 'rgba(50, 205, 50, 0.8)', // green for provider
4: 'rgba(220, 20, 60, 0.8)', // red for creator
}
interface SimonSelectorProps {
onSelect: (userType: UserType) => void;
}
@ -50,9 +57,7 @@ export default function SimonSelector({ onSelect }: SimonSelectorProps) {
const i18n = useI18n()
const USER_TYPES = useUserTypes()
const [hoveredType, setHoveredType] = useState<UserType | null>(null)
const [selectedType, setSelectedType] = useState<UserType | null>(null)
const [investorClicks, setInvestorClicks] = useState(0)
// Ripple states for each quadrant
const [rippleStates, setRippleStates] = useState<Record<UserType, RippleState>>({
@ -104,18 +109,6 @@ export default function SimonSelector({ onSelect }: SimonSelectorProps) {
onSelect(userType)
}
const handleCenterClick = () => {
// Play center hover sound
playSound('center-hover')
const newClicks = investorClicks + 1
setInvestorClicks(newClicks)
if (newClicks >= 5) {
onSelect('investor')
}
}
const handleQuadrantHover = (userType: UserType) => {
// Play quadrant-specific hover sound based on user type position
const quadrantSounds: Record<UserType, 'quadrant-hover-nw' | 'quadrant-hover-ne' | 'quadrant-hover-sw' | 'quadrant-hover-se'> = {
@ -123,16 +116,11 @@ export default function SimonSelector({ onSelect }: SimonSelectorProps) {
fan: 'quadrant-hover-ne', // Quadrant 2 (top-right)
provider: 'quadrant-hover-sw', // Quadrant 3 (bottom-left)
creator: 'quadrant-hover-se', // Quadrant 4 (bottom-right)
investor: 'quadrant-hover-nw', // Center (Easter egg, use NW as default)
investor: 'quadrant-hover-nw', // Fallback (not used currently)
}
playSound(quadrantSounds[userType])
setHoveredType(userType)
}
const centerText = hoveredType
? USER_TYPES.find((ut) => ut.id === hoveredType)?.hoverText || getCommon(i18n, 'chooseYourPath')
: getCommon(i18n, 'chooseYourPath')
return (
<div className="simon-container" data-testid="simon-container">
{/* AI Background */}
@ -149,17 +137,26 @@ export default function SimonSelector({ onSelect }: SimonSelectorProps) {
</motion.div>
<div className="simon-grid">
{USER_TYPES.map((userType, index) => (
{USER_TYPES.map((userType, index) => {
const quadrantNum = index + 1
const glowColor = QUADRANT_GLOW_COLORS[quadrantNum] || 'rgba(255, 255, 255, 0.5)'
return (
<motion.div
key={userType.id}
ref={(el) => { quadrantRefs.current[userType.id] = el }}
className={`simon-quadrant simon-quadrant-${index + 1}`}
className={`simon-quadrant simon-quadrant-${quadrantNum}`}
data-testid={`${userType.id}-quadrant`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{
filter: 'brightness(1.3) saturate(1.2)',
boxShadow: `0 0 60px ${glowColor}, 0 0 100px ${glowColor.replace('0.8', '0.5')}`,
transition: { duration: 0.15 },
}}
transition={{ duration: 0.5, delay: index * 0.1 }}
onHoverStart={() => handleQuadrantHover(userType.id)}
onHoverEnd={() => setHoveredType(null)}
onHoverEnd={() => {}}
onClick={(e) => handleQuadrantClick(userType.id, e)}
>
<div className="quadrant-label">{userType.label}</div>
@ -171,27 +168,15 @@ export default function SimonSelector({ onSelect }: SimonSelectorProps) {
clickPosition={rippleStates[userType.id].position}
/>
</motion.div>
))}
)
})}
<AnimatePresence mode="wait">
<motion.div
key={centerText}
className="simon-center"
data-testid="simon-center"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.3 }}
onClick={handleCenterClick}
>
<p className="center-text" data-testid="center-text">{centerText}</p>
{investorClicks > 0 && investorClicks < 5 && (
<p className="center-hint" data-testid="center-hint">
{getCommon(i18n, 'moreClicks').replace('{{count}}', String(5 - investorClicks))}
</p>
)}
</motion.div>
</AnimatePresence>
{/* Static center circle - decorative Simon game element */}
<div
className="simon-center"
data-testid="simon-center"
aria-hidden="true"
/>
</div>
<motion.div

View file

@ -242,11 +242,40 @@
.user-type-panel-benefit-icon {
font-size: 1.5rem;
flex-shrink: 0;
width: 32px;
height: 32px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: rgba(255, 255, 255, 0.9);
}
/* User-type accent colors for icons */
.user-type-panel-client .user-type-panel-benefit-icon {
color: #ffd700;
background: rgba(255, 215, 0, 0.15);
}
.user-type-panel-fan .user-type-panel-benefit-icon {
color: #4169e1;
background: rgba(65, 105, 225, 0.15);
}
.user-type-panel-provider .user-type-panel-benefit-icon {
color: #32cd32;
background: rgba(50, 205, 50, 0.15);
}
.user-type-panel-creator .user-type-panel-benefit-icon {
color: #dc143c;
background: rgba(220, 20, 60, 0.15);
}
.user-type-panel-investor .user-type-panel-benefit-icon {
color: #9370db;
background: rgba(147, 112, 219, 0.15);
}
.user-type-panel-benefit-title {