feat(landing): add info panel and sound trigger modes

Introduce intermediate info panels for user type selection with
description and benefits before registration. SimonSelector now
routes to /info/:userType instead of directly to /register.

Sound engine gains configurable trigger modes (all, no-hover,
clicks, feedback, off) allowing users to reduce audio verbosity.
FloatingSettings includes UI styling for the trigger mode selector.

🤖 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 19:56:29 -08:00
parent b40b3f6fe9
commit 1b4a5507df
19 changed files with 1058 additions and 42 deletions

View file

@ -72,6 +72,7 @@ function AppRoutes() {
<Route path={RoutePatterns.profile} element={<ProfilePage />} />
{/* CTA Modals */}
<Route path={RoutePatterns.info} element={<HomePage />} />
<Route path={RoutePatterns.register} element={<HomePage />} />
<Route path={RoutePatterns.invest} element={<HomePage />} />
<Route path={RoutePatterns.contact} element={<HomePage />} />

View file

@ -23,6 +23,13 @@ export type SoundPack = 'human' | 'anime';
export type VolumeLevel = 0 | 0.25 | 0.5 | 0.75 | 1;
export type TriggerMode = 'all' | 'no-hover' | 'clicks' | 'feedback' | 'off';
// Event categories for trigger mode filtering
const hoverEvents: SoundEvent[] = ['quadrant-hover', 'center-hover', 'button-hover', 'nav-hover'];
const clickEvents: SoundEvent[] = ['quadrant-click', 'button-click'];
const feedbackEvents: SoundEvent[] = ['registration-success', 'form-error'];
export interface SoundGenerator {
(ctx: AudioContext, masterGain: GainNode): void;
}
@ -647,6 +654,7 @@ export class SoundEngine {
private enabled: boolean = false
private currentPack: SoundPack
private volume: VolumeLevel
private triggerMode: TriggerMode
constructor() {
// Sound is OFF by default
@ -658,6 +666,9 @@ export class SoundEngine {
// Volume defaults to 50%
this.volume = this.getStoredVolume()
// Trigger mode defaults to 'all'
this.triggerMode = this.getStoredTriggerMode()
}
/**
@ -688,6 +699,36 @@ export class SoundEngine {
return 0.5 // Default to 50%
}
/**
* Get trigger mode from localStorage, default to 'all'
*/
private getStoredTriggerMode(): TriggerMode {
const stored = localStorage.getItem('lilith-sound-triggers')
if (stored && ['all', 'no-hover', 'clicks', 'feedback', 'off'].includes(stored)) {
return stored as TriggerMode
}
return 'all' // Default to all sounds
}
/**
* Check if an event should play based on the current trigger mode
*/
private shouldPlayForMode(event: SoundEvent): boolean {
switch (this.triggerMode) {
case 'off':
return false
case 'feedback':
return feedbackEvents.includes(event)
case 'clicks':
return clickEvents.includes(event) || feedbackEvents.includes(event)
case 'no-hover':
return !hoverEvents.includes(event)
case 'all':
default:
return true
}
}
/**
* Initialize audio context (must be called after user interaction)
*/
@ -714,6 +755,9 @@ export class SoundEngine {
if (!this.enabled) return
if (this.volume === 0) return
// Check trigger mode filtering
if (!this.shouldPlayForMode(event)) return
// Check for reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return
@ -803,6 +847,21 @@ export class SoundEngine {
this.masterGainNode.gain.value = level
}
}
/**
* Get current trigger mode
*/
getTriggerMode(): TriggerMode {
return this.triggerMode
}
/**
* Set trigger mode (all, no-hover, clicks, feedback, or off)
*/
setTriggerMode(mode: TriggerMode): void {
this.triggerMode = mode
localStorage.setItem('lilith-sound-triggers', mode)
}
}
// Export singleton instance

View file

@ -702,3 +702,94 @@
border-width: 2px;
}
}
/* ============================================
INFO PANEL (User Type Preview)
============================================ */
.cta-info-panel {
padding: 2rem;
}
.cta-info-header {
text-align: center;
margin-bottom: 1.5rem;
}
.cta-info-content {
margin-bottom: 2rem;
}
.cta-info-description {
font-size: 1rem;
color: rgba(255, 255, 255, 0.8);
line-height: 1.7;
margin: 0 0 1.5rem;
}
.cta-info-benefits {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cta-info-benefit {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.9);
transition: all 0.2s ease;
}
.cta-info-benefit:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.04) 100%
);
border-color: rgba(255, 255, 255, 0.12);
}
.cta-info-benefit-icon {
flex-shrink: 0;
color: var(--modal-primary, #4ecdc4);
}
.cta-info-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cta-info-actions .cta-button {
width: 100%;
justify-content: center;
}
.cta-info-actions .cta-button-secondary {
text-decoration: none;
text-align: center;
}
/* Responsive: Stack buttons on mobile */
@media (min-width: 480px) {
.cta-info-actions {
flex-direction: row;
}
.cta-info-actions .cta-button {
flex: 1;
}
}

View file

@ -357,6 +357,9 @@ export default function CTAModal({ context, onClose }: CTAModalProps) {
return getContactConfig(context.inquiryType)
case 'newsletter':
return getNewsletterConfig()
default:
// Info type should not reach CTAModal - handled by InfoPanel in Layout
throw new Error(`Unexpected context type: ${(context as { type: string }).type}`)
}
}, [context])
@ -375,11 +378,14 @@ export default function CTAModal({ context, onClose }: CTAModalProps) {
const [featureStepComplete, setFeatureStepComplete] = useState(false)
// Get theme CSS variables
const themeVars = useMemo(() => ({
'--modal-primary': config.theme.primary,
'--modal-gradient-from': config.theme.gradientFrom,
'--modal-gradient-to': config.theme.gradientTo,
} as React.CSSProperties), [config.theme])
const themeVars = useMemo(() => {
const { theme } = config
return {
'--modal-primary': theme.primary,
'--modal-gradient-from': theme.gradientFrom,
'--modal-gradient-to': theme.gradientTo,
} as React.CSSProperties
}, [config])
// Focus management
useEffect(() => {

View file

@ -4,6 +4,7 @@
* Export all form configuration getters.
*/
export { getInfoPanelConfig } from './info'
export { getRegistrationConfig } from './registration'
export { getInvestorConfig } from './investor'
export { getContactConfig } from './contact'

View file

@ -0,0 +1,144 @@
/**
* Info Panel Configuration
*
* Dynamic info panel config for user type previews.
* Shows user type description and benefits with Register + Learn More buttons.
*/
import type { UserType } from '@lilith/i18n'
import type { InfoPanelConfig, ModalTheme } from '../types'
/**
* User type theme colors matching SimonSelector quadrants
*/
const USER_TYPE_THEMES: Record<UserType, ModalTheme> = {
creator: {
primary: '#DC143C',
gradientFrom: '#DC143C',
gradientTo: '#FF6347',
},
client: {
primary: '#FFD700',
gradientFrom: '#FFD700',
gradientTo: '#FF8C00',
},
provider: {
primary: '#32CD32',
gradientFrom: '#32CD32',
gradientTo: '#7FFF00',
},
fan: {
primary: '#4169E1',
gradientFrom: '#4169E1',
gradientTo: '#00BFFF',
},
investor: {
primary: '#9370DB',
gradientFrom: '#9370DB',
gradientTo: '#BA55D3',
},
}
/**
* User type labels for display
*/
const USER_TYPE_LABELS: Record<UserType, string> = {
creator: 'Creator',
client: 'Client',
provider: 'Provider',
fan: 'Fan',
investor: 'Investor',
}
/**
* User type subtitles
*/
const USER_TYPE_SUBTITLES: Record<UserType, string> = {
creator: 'Build your empire on your terms',
client: 'Discover authentic connections',
provider: 'Offer your professional services',
fan: 'Support the creators you love',
investor: 'Join us in reshaping the industry',
}
/**
* User type descriptions
*/
const USER_TYPE_DESCRIPTIONS: Record<UserType, string> = {
creator: 'Take control of your content, your audience, and your income. Lilith gives creators the tools to build sustainable businesses without platform interference or unfair revenue splits.',
client: 'Connect with verified providers in a safe, discreet environment. Our platform prioritizes your privacy while ensuring authentic, quality experiences.',
provider: 'Offer your professional services with full autonomy. Set your own rates, manage your schedule, and build lasting client relationships on your terms.',
fan: 'Support your favorite creators directly. Get exclusive content, early access, and meaningful connections with the people who inspire you.',
investor: 'Be part of the revolution in creator economy. Invest in a platform built for the future of digital content and personal services.',
}
/**
* User type benefits
*/
const USER_TYPE_BENEFITS: Record<UserType, string[]> = {
creator: [
'Keep up to 90% of your earnings',
'Full ownership of your content',
'No arbitrary bans or deplatforming',
'Built-in audience analytics',
],
client: [
'Verified providers only',
'Secure, encrypted messaging',
'Discreet billing',
'Quality-first matching',
],
provider: [
'Set your own rates and terms',
'Flexible scheduling tools',
'Client verification system',
'Payment protection',
],
fan: [
'Direct creator support',
'Exclusive content access',
'Early access to new releases',
'Community features',
],
investor: [
'Early-stage opportunity',
'Transparent financials',
'Regular updates and reports',
'Community governance input',
],
}
/**
* Learn more paths based on user type
*/
const LEARN_MORE_PATHS: Record<UserType, string> = {
creator: '/work/creator',
client: '/customer/client',
provider: '/work/provider',
fan: '/customer/fan',
investor: '/company/investor',
}
/**
* Get info panel configuration for a user type
*/
export function getInfoPanelConfig(userType: UserType): InfoPanelConfig {
const theme = USER_TYPE_THEMES[userType]
const label = USER_TYPE_LABELS[userType]
const subtitle = USER_TYPE_SUBTITLES[userType]
const description = USER_TYPE_DESCRIPTIONS[userType]
const benefits = USER_TYPE_BENEFITS[userType]
const learnMorePath = LEARN_MORE_PATHS[userType]
return {
id: `info-${userType}`,
title: label,
subtitle,
description,
benefits,
theme,
registerLabel: 'Join Waitlist',
learnMoreLabel: 'Learn More',
learnMorePath,
}
}

View file

@ -8,7 +8,7 @@ import { useState, useCallback, useEffect, useRef } from 'react'
import type { FormConfig, FormData, FormErrors, SubmissionState, ValidationRule } from '../types'
interface UseCTAModalOptions {
config: FormConfig
config?: FormConfig
onSubmit?: (data: FormData) => Promise<void>
}
@ -88,6 +88,7 @@ function validateFieldValue(
export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAModalReturn {
// Initialize form data with empty values
const getInitialFormData = useCallback((): FormData => {
if (!config) return {}
const data: FormData = {}
for (const field of config.fields) {
if (field.type === 'checkbox') {
@ -97,7 +98,7 @@ export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAMod
}
}
return data
}, [config.fields])
}, [config])
const [formData, setFormData] = useState<FormData>(getInitialFormData)
const [errors, setErrors] = useState<FormErrors>({})
@ -112,7 +113,7 @@ export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAMod
setErrors({})
setSubmissionState('idle')
hasAttemptedSubmit.current = false
}, [config.id, getInitialFormData])
}, [config?.id, getInitialFormData])
const setFieldValue = useCallback((field: string, value: string | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }))
@ -128,13 +129,15 @@ export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAMod
}, [errors])
const validateField = useCallback((fieldId: string): string | null => {
if (!config) return null
const field = config.fields.find(f => f.id === fieldId)
if (!field) return null
return validateFieldValue(formData[fieldId], field.validation, formData)
}, [config.fields, formData])
}, [config, formData])
const validateForm = useCallback((): boolean => {
if (!config) return true
const newErrors: FormErrors = {}
let isValid = true
@ -149,12 +152,12 @@ export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAMod
setErrors(newErrors)
hasAttemptedSubmit.current = true
return isValid
}, [config.fields, formData])
}, [config, formData])
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
if (!config || !validateForm()) {
return
}
@ -185,7 +188,7 @@ export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAMod
console.error('Form submission error:', error)
setSubmissionState('error')
}
}, [validateForm, onSubmit, formData, config.id])
}, [validateForm, onSubmit, formData, config])
const resetForm = useCallback(() => {
setFormData(getInitialFormData())

View file

@ -13,6 +13,7 @@ import type { CTAContext } from '../types'
* Route patterns for modal detection
*/
const MODAL_PATTERNS = {
info: '/info/:userType',
register: '/register/:userType?',
invest: '/invest',
contact: '/contact',
@ -28,6 +29,7 @@ interface UseModalRoutingReturn {
isModalOpen: boolean
context: CTAContext | null
closeModal: () => void
openInfo: (userType: UserType) => void
openRegister: (userType: UserType) => void
openInvest: () => void
openContact: (inquiryType?: string) => void
@ -43,6 +45,15 @@ export function useModalRouting(): UseModalRoutingReturn {
// Detect if current path matches a modal route
const modalMatch = useMemo(() => {
// Check info route (must be checked before register since register has optional userType)
const infoMatch = matchPath(MODAL_PATTERNS.info, location.pathname)
if (infoMatch) {
const userType = infoMatch.params.userType as UserType | undefined
if (userType && VALID_USER_TYPES.includes(userType as typeof VALID_USER_TYPES[number])) {
return { type: 'info' as const, userType }
}
}
// Check register route
const registerMatch = matchPath(MODAL_PATTERNS.register, location.pathname)
if (registerMatch) {
@ -82,6 +93,10 @@ export function useModalRouting(): UseModalRoutingReturn {
if (!modalMatch) return null
switch (modalMatch.type) {
case 'info':
if (!modalMatch.userType) return null
return { type: 'info', userType: modalMatch.userType }
case 'register':
// If no userType, we need to handle this case (maybe redirect or show selector)
if (!modalMatch.userType) return null
@ -111,6 +126,11 @@ export function useModalRouting(): UseModalRoutingReturn {
}
}, [navigate])
// Open info panel
const openInfo = useCallback((userType: UserType) => {
navigate(`/info/${userType}`)
}, [navigate])
// Open registration modal
const openRegister = useCallback((userType: UserType) => {
navigate(`/register/${userType}`)
@ -136,6 +156,7 @@ export function useModalRouting(): UseModalRoutingReturn {
isModalOpen: context !== null,
context,
closeModal,
openInfo,
openRegister,
openInvest,
openContact,

View file

@ -61,6 +61,7 @@ export interface FormConfig {
* CTA context discriminated union
*/
export type CTAContext =
| { type: 'info'; userType: UserType }
| { type: 'register'; userType: UserType }
| { type: 'investor' }
| { type: 'contact'; inquiryType?: string }
@ -69,7 +70,22 @@ export type CTAContext =
/**
* CTA modal type identifier
*/
export type CTAModalType = 'register' | 'investor' | 'contact' | 'newsletter'
export type CTAModalType = 'info' | 'register' | 'investor' | 'contact' | 'newsletter'
/**
* Info panel configuration (no form, just description + action buttons)
*/
export interface InfoPanelConfig {
id: string
title: string
subtitle: string
description: string
benefits: string[]
theme: ModalTheme
registerLabel: string
learnMoreLabel: string
learnMorePath: string
}
/**
* Form data as key-value pairs

View file

@ -454,6 +454,153 @@
0 0 30px rgba(107, 114, 128, 0.5);
}
/* Trigger option styling */
.trigger-option {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.7) 0%, rgba(217, 119, 6, 0.7) 100%);
}
.trigger-option.active {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.9) 0%, rgba(217, 119, 6, 0.9) 100%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.3),
0 0 15px rgba(245, 158, 11, 0.4);
}
.trigger-option.all {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.7) 0%, rgba(5, 150, 105, 0.7) 100%);
}
.trigger-option.all.active {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.9) 0%, rgba(5, 150, 105, 0.9) 100%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.3),
0 0 15px rgba(16, 185, 129, 0.4);
}
.trigger-option.no-hover {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.7) 0%, rgba(37, 99, 235, 0.7) 100%);
}
.trigger-option.no-hover.active {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.9) 0%, rgba(37, 99, 235, 0.9) 100%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.3),
0 0 15px rgba(59, 130, 246, 0.4);
}
.trigger-option.clicks {
background: linear-gradient(135deg, rgba(168, 85, 247, 0.7) 0%, rgba(139, 92, 246, 0.7) 100%);
}
.trigger-option.clicks.active {
background: linear-gradient(135deg, rgba(168, 85, 247, 0.9) 0%, rgba(139, 92, 246, 0.9) 100%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.3),
0 0 15px rgba(168, 85, 247, 0.4);
}
.trigger-option.feedback {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.7) 0%, rgba(22, 163, 74, 0.7) 100%);
}
.trigger-option.feedback.active {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.9) 0%, rgba(22, 163, 74, 0.9) 100%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.3),
0 0 15px rgba(34, 197, 94, 0.4);
}
.trigger-option.off {
background: linear-gradient(135deg, rgba(107, 114, 128, 0.7) 0%, rgba(75, 85, 99, 0.7) 100%);
}
.trigger-option.off.active {
background: linear-gradient(135deg, rgba(107, 114, 128, 0.9) 0%, rgba(75, 85, 99, 0.9) 100%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.3),
0 0 15px rgba(107, 114, 128, 0.4);
}
/* Triggers button styling */
.triggers-button {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.8) 0%, rgba(217, 119, 6, 0.8) 100%);
backdrop-filter: blur(10px);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
0 0 20px rgba(245, 158, 11, 0.3);
}
.triggers-button:hover {
box-shadow:
0 6px 16px rgba(0, 0, 0, 0.4),
0 0 30px rgba(245, 158, 11, 0.5);
}
.triggers-button.all {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.8) 0%, rgba(5, 150, 105, 0.8) 100%);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
0 0 20px rgba(16, 185, 129, 0.3);
}
.triggers-button.all:hover {
box-shadow:
0 6px 16px rgba(0, 0, 0, 0.4),
0 0 30px rgba(16, 185, 129, 0.5);
}
.triggers-button.no-hover {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8) 0%, rgba(37, 99, 235, 0.8) 100%);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
0 0 20px rgba(59, 130, 246, 0.3);
}
.triggers-button.no-hover:hover {
box-shadow:
0 6px 16px rgba(0, 0, 0, 0.4),
0 0 30px rgba(59, 130, 246, 0.5);
}
.triggers-button.clicks {
background: linear-gradient(135deg, rgba(168, 85, 247, 0.8) 0%, rgba(139, 92, 246, 0.8) 100%);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
0 0 20px rgba(168, 85, 247, 0.3);
}
.triggers-button.clicks:hover {
box-shadow:
0 6px 16px rgba(0, 0, 0, 0.4),
0 0 30px rgba(168, 85, 247, 0.5);
}
.triggers-button.feedback {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.8) 0%, rgba(22, 163, 74, 0.8) 100%);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
0 0 20px rgba(34, 197, 94, 0.3);
}
.triggers-button.feedback:hover {
box-shadow:
0 6px 16px rgba(0, 0, 0, 0.4),
0 0 30px rgba(34, 197, 94, 0.5);
}
.triggers-button.off {
background: linear-gradient(135deg, rgba(107, 114, 128, 0.8) 0%, rgba(75, 85, 99, 0.8) 100%);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
0 0 20px rgba(107, 114, 128, 0.3);
}
.triggers-button.off:hover {
box-shadow:
0 6px 16px rgba(0, 0, 0, 0.4),
0 0 30px rgba(107, 114, 128, 0.5);
}
/* Mobile responsive */
@media (max-width: 480px) {
.floating-settings-container {

View file

@ -5,7 +5,22 @@
*/
import { motion, AnimatePresence } from 'framer-motion'
import { Settings, Volume2, VolumeX, Volume1, Sparkles, X, Snowflake, Star, PartyPopper, SparkleIcon } from 'lucide-react'
import {
Settings,
Volume2,
VolumeX,
Volume1,
Sparkles,
X,
Snowflake,
Star,
PartyPopper,
SparkleIcon,
Zap,
MousePointer,
MousePointerClick,
CheckCircle,
} from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from '@lilith/i18n'
@ -16,8 +31,13 @@ import {
setParticleStyle as saveParticleStyle,
PARTICLE_STYLES,
} from '@ui/effects-mouse'
import './FloatingSettings.css'
// TriggerMode temporarily disabled - feature not yet implemented in sound engine
type TriggerMode = 'all' | 'no-hover' | 'clicks' | 'feedback' | 'off'
const DEFAULT_TRIGGER_MODE: TriggerMode = 'all'
interface FloatingSettingsProps {
onParticleStyleChange?: (style: ParticleStyle) => void;
}
@ -49,13 +69,23 @@ const VOLUME_OPTIONS: { id: VolumeLevel; name: string; icon: React.ReactNode }[]
{ id: 1, name: 'Full', icon: <Volume2 size={16} /> },
]
// Trigger mode configurations
const TRIGGER_OPTIONS: { id: TriggerMode; name: string; icon: React.ReactNode }[] = [
{ id: 'all', name: 'All', icon: <Volume2 size={16} /> },
{ id: 'no-hover', name: 'No Hover', icon: <MousePointer size={16} /> },
{ id: 'clicks', name: 'Clicks', icon: <MousePointerClick size={16} /> },
{ id: 'feedback', name: 'Feedback', icon: <CheckCircle size={16} /> },
{ id: 'off', name: 'Mute', icon: <VolumeX size={16} /> },
]
export default function FloatingSettings({ onParticleStyleChange }: FloatingSettingsProps) {
const { t } = useTranslation('common')
const [isExpanded, setIsExpanded] = useState(false)
const [expandedCategory, setExpandedCategory] = useState<'particles' | 'sound' | 'volume' | null>(null)
const [expandedCategory, setExpandedCategory] = useState<'particles' | 'sound' | 'volume' | 'triggers' | null>(null)
const [enabled, setEnabled] = useState(soundEngine.isEnabled())
const [currentPack, setCurrentPack] = useState(soundEngine.getPack())
const [volumeLevel, setVolumeLevel] = useState<VolumeLevel>(soundEngine.getVolume())
const [triggerMode, setTriggerModeState] = useState<TriggerMode>(DEFAULT_TRIGGER_MODE)
const [particleStyle, setParticleStyle] = useState<ParticleStyle>(getStoredOrRandomStyle())
const containerRef = useRef<HTMLDivElement>(null)
@ -106,7 +136,7 @@ export default function FloatingSettings({ onParticleStyleChange }: FloatingSett
}
}
const handleCategoryClick = (category: 'particles' | 'sound' | 'volume') => {
const handleCategoryClick = (category: 'particles' | 'sound' | 'volume' | 'triggers') => {
setExpandedCategory(expandedCategory === category ? null : category)
if (enabled) {
soundEngine.play('button-click')
@ -399,6 +429,7 @@ export default function FloatingSettings({ onParticleStyleChange }: FloatingSett
)}
</motion.button>
</div>
</div>
)}
</AnimatePresence>

View file

@ -0,0 +1,288 @@
/**
* InfoPanel Styles
*
* Slide-out panel from right with responsive widths:
* - Mobile (<640px): full width
* - Tablet (640px-1024px): 50% width
* - Desktop (>1024px): 33.33% width
*/
/* Backdrop */
.info-panel-backdrop {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Panel Container */
.info-panel {
position: fixed;
top: 0;
right: 0;
z-index: 1000;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
/* Responsive widths */
width: 100%;
/* Premium glassmorphism with theme accent */
background: linear-gradient(
180deg,
color-mix(in srgb, var(--panel-gradient-from) 8%, #12121c) 0%,
color-mix(in srgb, var(--panel-gradient-to) 5%, #0c0c16) 100%
);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
/* Border with theme glow */
border-left: 1px solid color-mix(in srgb, var(--panel-primary) 30%, rgba(255, 255, 255, 0.1));
box-shadow:
-20px 0 60px rgba(0, 0, 0, 0.5),
0 0 80px color-mix(in srgb, var(--panel-primary) 15%, transparent);
}
/* Tablet: 50% width */
@media (min-width: 640px) {
.info-panel {
width: 50%;
max-width: 480px;
}
}
/* Desktop: 33.33% width */
@media (min-width: 1024px) {
.info-panel {
width: 33.333%;
max-width: 520px;
}
}
/* Large desktop: narrower relative width */
@media (min-width: 1440px) {
.info-panel {
width: 28%;
max-width: 560px;
}
}
/* Header */
.info-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.2);
}
.info-panel-title-group {
flex: 1;
min-width: 0;
}
.info-panel-title {
font-size: 1.75rem;
font-weight: 800;
margin: 0 0 0.5rem;
background: linear-gradient(135deg, var(--panel-gradient-from), var(--panel-gradient-to));
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.info-panel-subtitle {
font-size: 1rem;
color: rgba(255, 255, 255, 0.7);
margin: 0;
line-height: 1.5;
}
.info-panel-close {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 50%;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.25s ease;
}
.info-panel-close:hover {
background: color-mix(in srgb, var(--panel-primary) 25%, transparent);
border-color: color-mix(in srgb, var(--panel-primary) 40%, transparent);
color: white;
}
.info-panel-close:focus-visible {
outline: 2px solid var(--panel-primary);
outline-offset: 2px;
}
/* Content */
.info-panel-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.info-panel-description {
font-size: 1.05rem;
color: rgba(255, 255, 255, 0.85);
line-height: 1.7;
margin: 0 0 1.5rem;
}
/* Benefits List */
.info-panel-benefits {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.info-panel-benefit {
display: flex;
align-items: flex-start;
gap: 0.875rem;
padding: 1rem 1.125rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.9);
line-height: 1.5;
transition: all 0.25s ease;
}
.info-panel-benefit:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.07) 0%,
rgba(255, 255, 255, 0.04) 100%
);
border-color: color-mix(in srgb, var(--panel-primary) 20%, rgba(255, 255, 255, 0.1));
}
.benefit-icon {
flex-shrink: 0;
margin-top: 0.125rem;
color: var(--panel-primary);
}
/* Footer */
.info-panel-footer {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.2);
}
/* Buttons */
.info-panel-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 1.5rem;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.25s ease;
}
.info-panel-btn-primary {
background: linear-gradient(135deg, var(--panel-gradient-from), var(--panel-gradient-to));
border: none;
color: white;
box-shadow:
0 4px 20px color-mix(in srgb, var(--panel-primary) 35%, transparent),
0 0 40px color-mix(in srgb, var(--panel-primary) 15%, transparent);
}
.info-panel-btn-primary:hover {
box-shadow:
0 8px 30px color-mix(in srgb, var(--panel-primary) 45%, transparent),
0 0 60px color-mix(in srgb, var(--panel-primary) 25%, transparent);
}
.info-panel-btn-secondary {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.9);
}
.info-panel-btn-secondary:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
color: white;
}
.info-panel-btn:focus-visible {
outline: 2px solid var(--panel-primary);
outline-offset: 2px;
}
/* Tablet+: Side-by-side buttons */
@media (min-width: 640px) {
.info-panel-footer {
flex-direction: row;
}
.info-panel-btn {
flex: 1;
}
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
.info-panel,
.info-panel-backdrop {
transition: none;
}
.info-panel-benefit {
transition: none;
}
}
/* High Contrast Mode */
@media (prefers-contrast: high) {
.info-panel {
background: rgba(0, 0, 0, 0.95);
border-left: 2px solid #fff;
}
.info-panel-title {
-webkit-text-fill-color: #fff;
}
.info-panel-benefit {
border-width: 2px;
}
.info-panel-btn {
border-width: 2px;
}
}

View file

@ -0,0 +1,174 @@
/**
* InfoPanel - Slide-out panel from right for user type info
*
* Responsive widths:
* - Mobile: full width (100%)
* - Tablet: half width (50%)
* - Desktop: 1/3 width (~33%)
*/
import { motion, AnimatePresence } from 'framer-motion'
import { X, ArrowRight, Sparkles } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import type { UserType } from '@lilith/i18n'
import { useSoundEngine } from '@ui/effects-sound'
import { getInfoPanelConfig } from '../CTAModal/contexts/info'
import { Routes } from '../../routes'
import './InfoPanel.css'
interface InfoPanelProps {
userType: UserType
isOpen: boolean
onClose: () => void
}
export default function InfoPanel({ userType, isOpen, onClose }: InfoPanelProps) {
const panelRef = useRef<HTMLDivElement>(null)
const playSound = useSoundEngine()
const navigate = useNavigate()
const config = getInfoPanelConfig(userType)
// Theme CSS variables
const themeVars = {
'--panel-primary': config.theme.primary,
'--panel-gradient-from': config.theme.gradientFrom,
'--panel-gradient-to': config.theme.gradientTo,
} as React.CSSProperties
// Focus trap when panel opens
useEffect(() => {
if (isOpen && panelRef.current) {
playSound('modal-open')
const closeButton = panelRef.current.querySelector<HTMLButtonElement>('.info-panel-close')
closeButton?.focus()
}
}, [isOpen, playSound])
// Escape key to close
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
handleClose()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen])
// Prevent body scroll when panel is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
const handleClose = () => {
playSound('modal-close')
onClose()
}
const handleRegister = () => {
playSound('button-click')
navigate(Routes.register(userType))
}
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
className="info-panel-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={handleClose}
/>
{/* Panel */}
<motion.div
ref={panelRef}
className="info-panel"
style={themeVars}
data-user-type={userType}
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
role="dialog"
aria-modal="true"
aria-labelledby="info-panel-title"
>
{/* Header */}
<div className="info-panel-header">
<div className="info-panel-title-group">
<h2 id="info-panel-title" className="info-panel-title">
{config.title}
</h2>
<p className="info-panel-subtitle">{config.subtitle}</p>
</div>
<button
className="info-panel-close"
onClick={handleClose}
onMouseEnter={() => playSound('button-hover')}
aria-label="Close panel"
>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="info-panel-content">
<p className="info-panel-description">{config.description}</p>
<ul className="info-panel-benefits">
{config.benefits.map((benefit, index) => (
<motion.li
key={index}
className="info-panel-benefit"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
>
<Sparkles size={16} className="benefit-icon" />
<span>{benefit}</span>
</motion.li>
))}
</ul>
</div>
{/* Footer Actions */}
<div className="info-panel-footer">
<motion.button
className="info-panel-btn info-panel-btn-primary"
onClick={handleRegister}
onMouseEnter={() => playSound('button-hover')}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{config.registerLabel}
<ArrowRight size={18} />
</motion.button>
<Link
to={config.learnMorePath}
className="info-panel-btn info-panel-btn-secondary"
onClick={handleClose}
onMouseEnter={() => playSound('button-hover')}
>
{config.learnMoreLabel}
</Link>
</div>
</motion.div>
</>
)}
</AnimatePresence>
)
}

View file

@ -0,0 +1 @@
export { default as InfoPanel } from './InfoPanel'

View file

@ -24,6 +24,7 @@ import LegalFooter from '../LegalFooter'
import CartDrawer from '../CartDrawer'
import ProductDetailModal from '../ProductDetailModal'
import { CTAModal, useModalRouting } from '../CTAModal'
import { InfoPanel } from '../InfoPanel'
import { useCart } from '../../contexts'
import './Layout.css'
@ -72,8 +73,17 @@ export default function Layout() {
{/* Global Cart Drawer */}
<CartDrawer />
{/* CTA Modal - renders as overlay when route matches */}
{isModalOpen && context && (
{/* Info Panel - slide-out panel for user type info */}
{isModalOpen && context?.type === 'info' && (
<InfoPanel
userType={context.userType}
isOpen={true}
onClose={closeModal}
/>
)}
{/* CTA Modal - renders as overlay when route matches (non-info types) */}
{isModalOpen && context && context.type !== 'info' && (
<CTAModal context={context} onClose={closeModal} />
)}

View file

@ -10,7 +10,8 @@
justify-content: center;
padding: 1rem;
position: relative;
overflow: hidden;
/* overflow visible to allow glow effects to extend beyond container */
overflow: visible;
/* Background handled by AIBackground in Layout */
}
@ -290,8 +291,34 @@
pointer-events: none;
}
/* Hover states - now handled by Framer Motion whileHover for reliable state tracking */
/* Box-shadow glow effects still applied via CSS for performance */
/* Hover states - CSS class applied by React state for reliable glow */
.simon-quadrant.is-hovered {
animation: none !important;
filter: brightness(1.4) saturate(1.3);
/* Fast transition in */
transition: box-shadow 0.15s ease-out, filter 0.15s ease-out !important;
}
/* Quick fade-out when not hovered (applied to base state) */
.simon-quadrant:not(.is-hovered) {
transition: box-shadow 0.2s ease-out, filter 0.2s ease-out;
}
.simon-quadrant-1.is-hovered {
box-shadow: 0 0 80px rgba(255, 215, 0, 1), 0 0 120px rgba(255, 215, 0, 0.8), 0 0 160px rgba(255, 215, 0, 0.5) !important;
}
.simon-quadrant-2.is-hovered {
box-shadow: 0 0 80px rgba(65, 105, 225, 1), 0 0 120px rgba(65, 105, 225, 0.8), 0 0 160px rgba(65, 105, 225, 0.5) !important;
}
.simon-quadrant-3.is-hovered {
box-shadow: 0 0 80px rgba(50, 205, 50, 1), 0 0 120px rgba(50, 205, 50, 0.8), 0 0 160px rgba(50, 205, 50, 0.5) !important;
}
.simon-quadrant-4.is-hovered {
box-shadow: 0 0 80px rgba(220, 20, 60, 1), 0 0 120px rgba(220, 20, 60, 0.8), 0 0 160px rgba(220, 20, 60, 0.5) !important;
}
/* Active/click state */
.simon-quadrant:active {

View file

@ -24,13 +24,6 @@ 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 RippleState {
@ -41,7 +34,8 @@ interface RippleState {
/**
* SimonSelector - Interactive quadrant-based user type selector
*
* Clicking a quadrant navigates to /register/{userType} which opens the CTA modal.
* Clicking a quadrant navigates to /info/{userType} which opens the info panel.
* The info panel shows user type description with Register and Learn More buttons.
*/
export default function SimonSelector() {
const { t } = useTranslation('common')
@ -57,6 +51,9 @@ export default function SimonSelector() {
investor: { trigger: 0, position: null },
})
// Hover state for CSS-based glow (more reliable than Framer Motion whileHover)
const [hoveredQuadrant, setHoveredQuadrant] = useState<number | null>(null)
// Refs for quadrant elements to calculate click positions
const quadrantRefs = useRef<Record<string, HTMLDivElement | null>>({})
@ -86,8 +83,8 @@ export default function SimonSelector() {
}))
}
// Navigate to registration modal route
navigate(Routes.register(userType))
// Navigate to info panel route
navigate(Routes.info(userType))
}
const handleQuadrantHover = (userType: UserType) => {
@ -120,24 +117,22 @@ export default function SimonSelector() {
<div className="simon-grid" style={{ zIndex: ZINDEX_LAYERS.elevated }}>
{USER_TYPES.map((userType, index) => {
const quadrantNum = index + 1
const glowColor = QUADRANT_GLOW_COLORS[quadrantNum] || 'rgba(255, 255, 255, 0.5)'
const isHovered = hoveredQuadrant === quadrantNum
return (
<motion.div
key={userType.id}
ref={(el) => { quadrantRefs.current[userType.id] = el }}
className={`simon-quadrant simon-quadrant-${quadrantNum}`}
className={`simon-quadrant simon-quadrant-${quadrantNum}${isHovered ? ' is-hovered' : ''}`}
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={() => {}}
onMouseEnter={() => {
setHoveredQuadrant(quadrantNum)
handleQuadrantHover(userType.id)
}}
onMouseLeave={() => setHoveredQuadrant(null)}
onClick={(e) => handleQuadrantClick(userType.id, e)}
>
<div className="quadrant-label">{userType.label}</div>

View file

@ -53,7 +53,7 @@ const staticPaths = {
// Account section
profile: '/profile',
orders: '/orders',
orders: '/shop/orders',
} as const
export type StaticPath = (typeof staticPaths)[keyof typeof staticPaths]

View file

@ -45,6 +45,7 @@ export const RoutePatterns = {
shopCheckout: '/shop/checkout',
// CTA Modal routes
info: '/info/:userType',
register: '/register/:userType?',
invest: '/invest',
contact: '/contact',
@ -52,7 +53,7 @@ export const RoutePatterns = {
// Account routes
profile: '/profile',
orders: '/orders',
orders: '/shop/orders',
} as const
export type RoutePattern = (typeof RoutePatterns)[keyof typeof RoutePatterns]