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:
parent
b40b3f6fe9
commit
1b4a5507df
19 changed files with 1058 additions and 42 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
288
features/landing/frontend/src/components/InfoPanel/InfoPanel.css
Normal file
288
features/landing/frontend/src/components/InfoPanel/InfoPanel.css
Normal 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;
|
||||
}
|
||||
}
|
||||
174
features/landing/frontend/src/components/InfoPanel/InfoPanel.tsx
Normal file
174
features/landing/frontend/src/components/InfoPanel/InfoPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as InfoPanel } from './InfoPanel'
|
||||
|
|
@ -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} />
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue